From cf2c126a9513e5a10229bda7cb2b74c724865f53 Mon Sep 17 00:00:00 2001 From: nolt Date: Sat, 27 Jun 2026 23:08:16 +0200 Subject: [PATCH 01/60] feat(bots): server-side bot AI foundation (phase 1) Reuse the connection-less OfflinePlayer and its MU Helper AI for standalone server-side bots: - Make the hunting origin dynamic (OfflinePlayer.HuntingOrigin) so a bot can roam between hunting grounds; the combat and movement handlers read it instead of a fixed spawn position. - Add BotPlayer (respawns and keeps going) and BotNavigator, which picks a level-appropriate hunting ground from the map's monster spawns and walks there with a full-grid path finder, then hunts locally. The travel stop range matches the combat hunting range, so a bot never stalls next to a monster it cannot reach. - Add BotMuHelperSettings giving the bot a real hunting range so it actually fights; auto-repair is disabled to avoid the offline zen drain. --- src/GameLogic/Bots/BotMuHelperSettings.cs | 169 ++++++++ src/GameLogic/Bots/BotNavigator.cs | 369 ++++++++++++++++++ src/GameLogic/Bots/BotPlayer.cs | 60 +++ src/GameLogic/Offline/CombatHandler.cs | 14 +- src/GameLogic/Offline/MovementHandler.cs | 16 +- src/GameLogic/Offline/OfflinePlayer.cs | 24 +- .../Offline/OfflinePlayerMuHelper.cs | 19 +- 7 files changed, 651 insertions(+), 20 deletions(-) create mode 100644 src/GameLogic/Bots/BotMuHelperSettings.cs create mode 100644 src/GameLogic/Bots/BotNavigator.cs create mode 100644 src/GameLogic/Bots/BotPlayer.cs diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs new file mode 100644 index 000000000..5ed2836db --- /dev/null +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -0,0 +1,169 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.GameLogic.MuHelper; + +/// +/// Default MU Helper settings used to drive a bot's combat AI. +/// A bot never sends a client-side MU Helper configuration, so without this the player would +/// fall back to a hunting range of a single tile (see ). +/// These defaults make the bot hunt nearby monsters, pick up the valuable drops and use +/// potions, while staying close to its spawn origin. +/// +internal sealed class BotMuHelperSettings : IMuHelperSettings +{ + /// + public int BasicSkillId => 0; + + /// + public int ActivationSkill1Id => 0; + + /// + public int ActivationSkill2Id => 0; + + /// + public int DelayMinSkill1 => 0; + + /// + public int DelayMinSkill2 => 0; + + /// + public bool Skill1UseTimer => false; + + /// + public bool Skill1UseCondition => false; + + /// + public bool Skill1ConditionAttacking => false; + + /// + public int Skill1SubCondition => 0; + + /// + public bool Skill2UseTimer => false; + + /// + public bool Skill2UseCondition => false; + + /// + public bool Skill2ConditionAttacking => false; + + /// + public int Skill2SubCondition => 0; + + /// + public bool UseCombo => false; + + /// + public int HuntingRange => 6; + + /// + public int MaxSecondsAway => 30; + + /// + public bool LongRangeCounterAttack => false; + + /// + /// + /// Disabled for bots: the is the sole driver of travel between hunting + /// grounds, so the offline movement handler must not try to walk the bot back to its origin in parallel. + /// + public bool ReturnToOriginalPosition => false; + + /// + public int BuffSkill0Id => 0; + + /// + public int BuffSkill1Id => 0; + + /// + public int BuffSkill2Id => 0; + + /// + public bool BuffOnDuration => false; + + /// + public bool BuffDurationForParty => false; + + /// + public int BuffCastIntervalSeconds => 0; + + /// + public bool AutoHeal => false; + + /// + public int HealThresholdPercent => 30; + + /// + public bool UseDrainLife => false; + + /// + public bool UseHealPotion => true; + + /// + public int PotionThresholdPercent => 60; + + /// + public bool SupportParty => false; + + /// + public bool AutoHealParty => false; + + /// + public int HealPartyThresholdPercent => 0; + + /// + public bool UseDarkRaven => false; + + /// + public int DarkRavenMode => 0; + + /// + public int ObtainRange => 6; + + /// + public bool PickAllItems => false; + + /// + public bool PickSelectItems => false; + + /// + public bool PickJewel => true; + + /// + public bool PickZen => true; + + /// + public bool PickAncient => true; + + /// + public bool PickExcellent => true; + + /// + public bool PickExtraItems => false; + + /// + public IReadOnlyList ExtraItemNames => Array.Empty(); + + /// + /// + /// Disabled on purpose: offline auto-repair has no NPC discount and drains Zen at an + /// increased rate. Bots should not burn their balance on repairs during the proof of concept. + /// + public bool RepairItem => false; + + /// + public bool UseSelfDefense => false; + + /// + public bool AutoAcceptFriend => false; + + /// + public bool AutoAcceptGuild => false; + + /// + public bool FallbackBasicAttack => true; +} diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs new file mode 100644 index 000000000..ba6638da9 --- /dev/null +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -0,0 +1,369 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Threading; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.Pathfinding; + +/// +/// Drives a bot's high-level navigation. It picks a hunting ground that suits the bot's level from +/// the monster spawn areas of the current map and points the bot's +/// at it. The existing offline movement handler then walks the bot there (using the server pathfinder) +/// and the combat handler hunts locally - so a bot which spawns in town finds its own way to the monsters. +/// +internal sealed class BotNavigator : AsyncDisposable +{ + private static readonly TimeSpan StartDelay = TimeSpan.FromSeconds(2); + private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); + + /// Monsters up to this many levels below the bot still grant full experience (see ). + private const int FullExpLowerHeadroom = 10; + + /// Preferred upper bound: monsters a few levels above the bot are still reasonably safe to fight. + private const int SafeUpperHeadroom = 5; + + /// Width of the level band of areas we randomize between, so bots don't all stack on one spot. + private const int BandWidth = 3; + + /// Range (tiles) around the origin that counts as "at the hunting ground". + private const int HuntingRange = 6; + + /// Number of path steps to issue per travel hop. Short hops let the bot stop and fight between hops. + private const int StepsPerHop = 3; + + /// + /// If a monster is within this range the bot stops travelling and lets the combat handler engage. + /// MUST equal the combat hunting range: the combat handler only ever targets monsters within + /// of the bot, so stopping for anything further away would + /// hand control to the combat handler for a monster it never fights - the bot would freeze in place + /// (stop travelling, but find nothing to attack) until the monster happened to wander off. + /// + private const int TravelStopRange = HuntingRange; + + /// Search limit for the travel path finder, generous enough for a full cross-map route. + private const int TravelSearchLimit = 20000; + + /// Heuristic weight while travelling: high enough to make the A* search greedy and cheap over long routes. + private const int TravelHeuristicWeight = 12; + + private const int MaxPointPickAttempts = 25; + + /// + /// A shared full-map path finder for long-distance travel. The pooled path finder uses a small scoped + /// grid (it rejects start/end further apart than ~16 tiles), so it can only do local movement; this one + /// uses a which resolves routes across the whole map. A single instance is + /// reused under because a path finder is not safe for concurrent use. + /// + private static readonly PathFinder TravelPathFinder = CreateTravelPathFinder(); + + private static readonly SemaphoreSlim TravelPathFinderLock = new(1, 1); + + private readonly OfflinePlayer _player; + private readonly CancellationTokenSource _cts = new(); + + private Timer? _timer; + private DateTime? _emptyGroundSince; + private int _isEvaluating; + private Point _destination; + private bool _hasDestination; + + /// + /// Initializes a new instance of the class. + /// + /// The bot player. + public BotNavigator(OfflinePlayer player) + { + this._player = player; + } + + /// + /// Starts the periodic navigation evaluation. + /// + public void Start() + { + this._timer ??= new Timer( + _ => _ = this.SafeEvaluateAsync(this._cts.Token), + null, + StartDelay, + EvaluationInterval); + } + + /// + protected override async ValueTask DisposeAsyncCore() + { + await this._cts.CancelAsync().ConfigureAwait(false); + await base.DisposeAsyncCore().ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + this._timer?.Dispose(); + this._timer = null; + this._cts.Dispose(); + } + + base.Dispose(disposing); + } + + private async Task SafeEvaluateAsync(CancellationToken cancellationToken) + { + // The timer fires on a fixed interval regardless of whether the previous evaluation finished; + // skip overlapping runs so a single bot never has two travel walks / searches in flight. + if (Interlocked.CompareExchange(ref this._isEvaluating, 1, 0) != 0) + { + return; + } + + try + { + await this.EvaluateAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // expected during shutdown + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Bot navigator error for {Account}.", this._player.AccountLoginName); + } + finally + { + this._isEvaluating = 0; + } + } + + private async ValueTask EvaluateAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested + || this._player.PlayerState.CurrentState != PlayerState.EnteredWorld + || !this._player.IsAlive + || this._player.CurrentMap is not { } map) + { + return; + } + + // Keep the combat centre on the bot's current position, so the combat handler always engages + // monsters right next to the bot - both at the hunting ground and while travelling through hostile + // territory (self-defence). This is what keeps a travelling bot alive. The travel destination is + // tracked separately in _destination. + this._player.HuntingOrigin = this._player.Position; + + // A walk (travel hop or local combat move) is already in progress. + if (this._player.IsWalking) + { + return; + } + + var monstersNearby = map.GetAttackablesInRange(this._player.Position, TravelStopRange) + .OfType() + .Count(m => m.IsAlive && !m.IsAtSafezone()); + + // Monsters right here: stop and let the combat handler fight them (don't walk away into more danger). + if (monstersNearby > 0) + { + this._emptyGroundSince = null; + return; + } + + // Nothing to fight here. If we still have a destination to reach, keep walking towards it. + if (this._hasDestination && this._player.GetDistanceTo(this._destination) > HuntingRange) + { + await this.TravelTowardAsync(map, this._destination).ConfigureAwait(false); + return; + } + + // Arrived (or no destination) and the area is empty: wait out a short grace, then pick a new ground. + this._hasDestination = false; + this._emptyGroundSince ??= DateTime.UtcNow; + if (DateTime.UtcNow - this._emptyGroundSince < EmptyGroundGrace) + { + return; + } + + this._emptyGroundSince = null; + + if (this.TryPickHuntingGround(map, out var ground, out var groundLevel)) + { + this._destination = ground; + this._hasDestination = true; + this._player.Logger.LogInformation( + "Bot {Character} (level {Level}) heading to hunting ground {Ground} (monster level ~{MonsterLevel}).", + this._player.Name, + this.GetBotLevel(), + ground, + groundLevel); + } + else + { + this._player.Logger.LogDebug("Bot {Character}: no reachable hunting ground found on map {Map}.", this._player.Name, map.Definition.Name); + } + } + + /// + /// Walks the next stretch towards the destination. A full route is resolved with a heuristic search + /// (so it goes around walls and out of the town gate) and the first steps are + /// issued; the remainder is recomputed from the new position on the next tick. The safe zone is allowed + /// in the path so the bot can leave town. + /// + private async ValueTask TravelTowardAsync(GameMap map, Point destination) + { + var position = this._player.Position; + + IList? path; + await TravelPathFinderLock.WaitAsync().ConfigureAwait(false); + try + { + TravelPathFinder.ResetPathFinder(); + path = TravelPathFinder.FindPath(position, destination, map.Terrain.AIgrid, true); + } + finally + { + TravelPathFinderLock.Release(); + } + + this._player.Logger.LogDebug( + "Bot {Character}: travel {From} -> dest {Dest}, pathLen={PathLen}.", + this._player.Name, + position, + destination, + path?.Count ?? -1); + if (path is null || path.Count == 0) + { + return; + } + + var stepsCount = Math.Min(path.Count, StepsPerHop); + var steps = new WalkingStep[stepsCount]; + for (var i = 0; i < stepsCount; i++) + { + var node = path[i]; + var previous = i == 0 ? position : steps[i - 1].To; + steps[i] = new WalkingStep(previous, node.Point, previous.GetDirectionTo(node.Point)); + } + + await this._player.WalkToAsync(steps[stepsCount - 1].To, steps).ConfigureAwait(false); + } + + private static PathFinder CreateTravelPathFinder() + { + return new PathFinder(new FullGridNetwork(true)) + { + Heuristic = new ManhattanTravelHeuristic(), + HeuristicEstimate = TravelHeuristicWeight, + SearchLimit = TravelSearchLimit, + }; + } + + private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); + + /// + /// Picks a hunting ground point from the map's monster spawn areas, preferring monsters that suit the + /// bot's level: ideally within [level-10, level+5] (full experience, safe); otherwise the closest band + /// below (over-leveled bot) or the lowest band above (under-leveled bot). + /// + private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundLevel) + { + ground = default; + groundLevel = 0; + + var botLevel = this.GetBotLevel(); + var candidates = map.Definition.MonsterSpawns + .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!))) + .Where(x => x.Level > 0) + .ToList(); + if (candidates.Count == 0) + { + return false; + } + + var ideal = candidates.Where(x => x.Level >= botLevel - FullExpLowerHeadroom && x.Level <= botLevel + SafeUpperHeadroom).ToList(); + List<(MonsterSpawnArea Area, int Level)> band; + if (ideal.Count > 0) + { + var top = ideal.Max(x => x.Level); + band = ideal.Where(x => x.Level >= top - BandWidth).ToList(); + } + else + { + var below = candidates.Where(x => x.Level < botLevel - FullExpLowerHeadroom).ToList(); + if (below.Count > 0) + { + var top = below.Max(x => x.Level); + band = below.Where(x => x.Level >= top - BandWidth).ToList(); + } + else + { + var bottom = candidates.Min(x => x.Level); + band = candidates.Where(x => x.Level <= bottom + BandWidth).ToList(); + } + } + + // Weight the choice by spawn quantity and proximity, so the bot prefers nearby, dense grounds + // (shorter, safer travel) while keeping some variety. + var position = this._player.Position; + var chosen = band.SelectWeightedRandom(band.Select(x => GroundWeight(x.Area, position))); + if (chosen.Area is null) + { + return false; + } + + groundLevel = chosen.Level; + return this.TryPickWalkablePoint(map, chosen.Area, out ground); + } + + private static int GetMonsterLevel(MonsterDefinition definition) + => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f); + + private static int GroundWeight(MonsterSpawnArea area, Point from) + { + var centerX = (area.X1 + area.X2) / 2; + var centerY = (area.Y1 + area.Y2) / 2; + var distance = Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); + var proximity = Math.Max(1, 300 - distance); + return Math.Max(1, (int)area.Quantity) * proximity; + } + + /// + /// A Manhattan distance heuristic (the path finder applies its own estimate multiplier), used to turn the + /// otherwise unguided search into A* for long-distance bot travel. + /// + private sealed class ManhattanTravelHeuristic : IHeuristic + { + public int HeuristicEstimateMultiplier { get; set; } + + public int CalculateHeuristicDistance(Point location, Point target) + => this.HeuristicEstimateMultiplier * (Math.Abs(location.X - target.X) + Math.Abs(location.Y - target.Y)); + } + + private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, out Point point) + { + var minX = Math.Min(area.X1, area.X2); + var maxX = Math.Max(area.X1, area.X2); + var minY = Math.Min(area.Y1, area.Y2); + var maxY = Math.Max(area.Y1, area.Y2); + + for (var attempt = 0; attempt < MaxPointPickAttempts; attempt++) + { + var x = Rand.NextInt(minX, maxX + 1); + var y = Rand.NextInt(minY, maxY + 1); + if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y]) + { + point = new Point((byte)x, (byte)y); + return true; + } + } + + point = default; + return false; + } +} diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs new file mode 100644 index 000000000..1b0ae817d --- /dev/null +++ b/src/GameLogic/Bots/BotPlayer.cs @@ -0,0 +1,60 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// A connection-less bot player. It reuses the whole offline-player intelligence (combat, buffs, +/// healing, pickup) and adds a which makes it roam to level-appropriate +/// hunting grounds instead of standing on a fixed spawn position. +/// +public sealed class BotPlayer : OfflinePlayer +{ + private BotNavigator? _navigator; + + /// + /// Initializes a new instance of the class. + /// + /// The game context. + public BotPlayer(IGameContext gameContext) + : base(gameContext) + { + } + + /// + public override bool RespawnAndContinue => true; + + /// + public override async ValueTask StopAsync() + { + await this.StopNavigatorAsync().ConfigureAwait(false); + await base.StopAsync().ConfigureAwait(false); + } + + /// + protected override void StartIntelligence() + { + base.StartIntelligence(); + this._navigator = new BotNavigator(this); + this._navigator.Start(); + } + + /// + protected override async ValueTask DisposeAsyncCore() + { + await this.StopNavigatorAsync().ConfigureAwait(false); + await base.DisposeAsyncCore().ConfigureAwait(false); + } + + private async ValueTask StopNavigatorAsync() + { + if (this._navigator is { } navigator) + { + this._navigator = null; + await navigator.DisposeAsync().ConfigureAwait(false); + } + } +} diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 1ed9a3023..4f580c16b 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -32,7 +32,6 @@ public sealed class CombatHandler private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; private readonly MovementHandler _movementHandler; - private readonly Point _originPosition; private readonly ConditionalSkillSlot[] _conditionalSkillSlots; private IAttackable? _currentTarget; @@ -46,13 +45,11 @@ public sealed class CombatHandler /// The offline player. /// The MU helper settings. /// The movement handler. - /// The original position to hunt around. - 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), @@ -65,6 +62,11 @@ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHa /// public int SkillCooldownTicks => this._skillCooldownTicks; + /// + /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds. + /// + private Point OriginPosition => this._player.HuntingOrigin; + /// /// Gets the hunting range in tiles. /// @@ -214,7 +216,7 @@ private IEnumerable GetAttackableMonstersInHuntingRange() return []; } - return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) + return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange) .OfType() .Where(this.IsMonsterAttackable); } @@ -229,7 +231,7 @@ private bool IsTargetStillValid(IAttackable target) return target.IsAlive && !target.IsAtSafezone() && !target.IsTeleporting - && target.IsInRange(this._originPosition, this.HuntingRange); + && target.IsInRange(this.OriginPosition, this.HuntingRange); } private bool IsMonsterAttackable(Monster monster) diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs index ef4ddb2f4..77a12cf56 100644 --- a/src/GameLogic/Offline/MovementHandler.cs +++ b/src/GameLogic/Offline/MovementHandler.cs @@ -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 /// /// The offline player. /// The MU Helper configuration. - /// The original spawn position. - public MovementHandler(OfflinePlayer player, IMuHelperSettings? config, Point originPosition) + public MovementHandler(OfflinePlayer player, IMuHelperSettings? config) { this._player = player; this._config = config; - this._originPosition = originPosition; } + /// + /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds. + /// + private Point OriginPosition => this._player.HuntingOrigin; + /// /// Gets the hunting range in tiles. /// @@ -51,7 +53,7 @@ public async ValueTask RegroupAsync() 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; } @@ -71,7 +73,7 @@ public async ValueTask RegroupAsync() /// The range to stop within. public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) { - if (target.IsInRange(this._originPosition, this.HuntingRange)) + if (target.IsInRange(this.OriginPosition, this.HuntingRange)) { var walkTarget = this._player.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, range); await this.WalkToAsync(walkTarget).ConfigureAwait(false); @@ -120,7 +122,7 @@ private async ValueTask WalkToAsync(Point target) private bool ShouldRegroup(out double distance) { - distance = this._player.GetDistanceTo(this._originPosition); + distance = this._player.GetDistanceTo(this.OriginPosition); if (distance <= RegroupDistanceThreshold) { return false; diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 66a6bab23..3860cb382 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -7,12 +7,13 @@ 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; /// /// An offline player that continues leveling after the real client disconnects. /// -public sealed class OfflinePlayer : Player +public class OfflinePlayer : Player { private OfflinePlayerMuHelper? _intelligence; @@ -35,6 +36,18 @@ public OfflinePlayer(IGameContext gameContext) /// public DateTime StartTimestamp { get; internal set; } + /// + /// 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. + /// + public Point HuntingOrigin { get; set; } + + /// + /// 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. + /// + public virtual bool RespawnAndContinue => false; + /// /// Initializes the offline player from captured references. /// @@ -55,6 +68,8 @@ public async ValueTask InitializeAsync(Account account, Character characte await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + this.HuntingOrigin = this.Position; + this.StartIntelligence(); this.Logger.LogDebug( @@ -75,7 +90,7 @@ public async ValueTask InitializeAsync(Account account, Character characte /// /// Stops the offline player and removes it from the world. /// - public async ValueTask StopAsync() + public virtual async ValueTask StopAsync() { if (this._intelligence is { } intelligence) { @@ -119,7 +134,10 @@ private async ValueTask SetupCharacterAsync(Character character) } } - private void StartIntelligence() + /// + /// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation. + /// + protected virtual void StartIntelligence() { this._intelligence = new OfflinePlayerMuHelper(this); this._intelligence.Start(); diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 2804e3958..79dbcce6f 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -47,14 +47,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); @@ -110,7 +109,9 @@ private void OnPlayerDied(DeathInformation e) { this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName); this._isDead = true; - this._cts.Cancel(); + + // 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 SafeTickAsync(CancellationToken cancellationToken) @@ -189,6 +190,16 @@ private async ValueTask HandleDeathAsync() 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. + this._isDead = false; + this._player.HuntingOrigin = this._player.Position; + 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); From 603b29ee501d7a59a69d8fed767c03ad39b4ea75 Mon Sep 17 00:00:00 2001 From: nolt Date: Sat, 27 Jun 2026 23:08:25 +0200 Subject: [PATCH 02/60] feat(bots): persistent bot population with generation and reset (phase 2) - Add the Account.IsBot flag (and its migration) as the reliable marker for bot accounts, independent of their names. - Generate a configurable population (N accounts with up to 5 characters each) with unique, realistic procedurally-generated character names and random creatable classes and levels. Generation is idempotent and the accounts are persistent, so they survive restarts and are loaded instead of regenerated. - Spawn every bot character on startup. Several bots animate different characters of the same account at once; this is safe because the shared account row is only attached, never modified. - Add a "Reset bots" configuration flag which purges all bot accounts and regenerates them on the next start, then clears itself again. - The bot feature plugin appears in the admin panel "Features" section and keeps the proof-of-concept account hook as an optional extra. --- src/DataModel/Entities/Account.cs | 7 + src/GameLogic/Bots/BotConfiguration.cs | 76 +++++++ src/GameLogic/Bots/BotFeaturePlugIn.cs | 185 +++++++++++++++ src/GameLogic/Bots/BotGenerator.cs | 214 ++++++++++++++++++ src/GameLogic/Bots/BotManager.cs | 130 +++++++++++ src/GameLogic/Bots/BotNameGenerator.cs | 91 ++++++++ .../20260627120000_AddAccountIsBot.cs | 39 ++++ .../EntityDataContextModelSnapshot.cs | 3 + 8 files changed, 745 insertions(+) create mode 100644 src/GameLogic/Bots/BotConfiguration.cs create mode 100644 src/GameLogic/Bots/BotFeaturePlugIn.cs create mode 100644 src/GameLogic/Bots/BotGenerator.cs create mode 100644 src/GameLogic/Bots/BotManager.cs create mode 100644 src/GameLogic/Bots/BotNameGenerator.cs create mode 100644 src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs diff --git a/src/DataModel/Entities/Account.cs b/src/DataModel/Entities/Account.cs index db22a7174..9a543c4c2 100644 --- a/src/DataModel/Entities/Account.cs +++ b/src/DataModel/Entities/Account.cs @@ -130,6 +130,13 @@ public class Account /// public bool IsTemplate { get; set; } + /// + /// Gets or sets a value indicating whether this account is a server-side bot account. + /// Bot accounts are generated and maintained by the bot feature; this flag is the reliable + /// marker used to load them on startup (instead of regenerating) and to purge them. + /// + public bool IsBot { get; set; } + /// /// Gets or sets the characters. /// diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs new file mode 100644 index 000000000..6fb35c772 --- /dev/null +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -0,0 +1,76 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.ComponentModel.DataAnnotations; + +/// +/// The admin-panel editable configuration of the . +/// +public class BotConfiguration +{ + /// + /// The hard limit of characters a single account can hold in the game. + /// + public const int MaxCharactersPerAccountLimit = 5; + + /// + /// Gets or sets a value indicating whether the bot feature is enabled. + /// Disabled by default so that enabling bots is always an explicit, deliberate action. + /// + [Display(Name = "Enabled", Description = "If enabled, bots are spawned after the server has started.")] + public bool Enabled { get; set; } + + /// + /// Gets or sets a value indicating whether all bot accounts and characters should be deleted. + /// When set, the feature purges every bot account on the next startup before generating fresh + /// ones, and then automatically clears this flag again. Use it to reset the bot population. + /// + [Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")] + public bool ResetBots { get; set; } + + /// + /// Gets or sets the number of bot accounts. Together with + /// this defines the bot population, e.g. 10 accounts × 5 characters = 50 bot characters. + /// + /// Used by the generation step (next phase). The proof of concept animates the + /// accounts listed in instead. + [Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")] + [Range(0, 1000)] + public int NumberOfAccounts { get; set; } = 10; + + /// + /// Gets or sets the number of characters per bot account. An account can hold at most + /// (5) characters, so this value is clamped on use. + /// + [Display(Name = "Characters per account", Description = "How many characters each bot account holds (max 5).")] + [Range(1, MaxCharactersPerAccountLimit)] + public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit; + + /// + /// Gets or sets a comma separated list of login names of existing accounts to animate as bots. + /// This is the proof-of-concept entry point: every listed account gets a bot driving its + /// first character. A later phase replaces this with generated, persistent bot accounts. + /// + [Display(Name = "Proof of concept accounts", Description = "Comma separated login names of existing accounts to animate as bots (PoC).")] + public string ProofOfConceptAccounts { get; set; } = string.Empty; + + /// + /// Gets the effective, clamped number of characters per account. + /// + /// A value between 1 and . + public int GetEffectiveCharactersPerAccount() + => Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit); + + /// + /// Parses into the distinct, trimmed login names. + /// + /// The list of login names. + public IReadOnlyList GetProofOfConceptAccounts() + => this.ProofOfConceptAccounts + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); +} diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs new file mode 100644 index 000000000..028b174e9 --- /dev/null +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -0,0 +1,185 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Feature plugin which spawns and maintains server-side bots. +/// Appears in the "Feature Plugins" section of the admin panel next to the MU Helper and reset features. +/// +[PlugIn] +[Display(Name = "Bots", Description = "Spawns server-side bots which hunt monsters on the maps. Configure the accounts to animate and enable the feature.")] +[Guid("6F3A2B91-7C4E-4D88-9A1F-2E5C0B7A4D63")] +public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +{ + /// + /// Delay before the first spawn attempt, giving the server time to finish starting up + /// (maps, configuration and plugins fully initialized). + /// + private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); + + private readonly BotManager _botManager = new(); + + private DateTime _nextRunUtc = DateTime.UtcNow + StartupDelay; + private bool _spawned; + + /// + public BotConfiguration? Configuration { get; set; } + + /// + /// Gets the bot manager. + /// + public BotManager BotManager => this._botManager; + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + if (this._spawned || DateTime.UtcNow < this._nextRunUtc) + { + return; + } + + var configuration = this.Configuration ??= CreateDefaultConfiguration(); + if (!configuration.Enabled) + { + return; + } + + this._spawned = true; + + var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); + using var scope = logger.BeginScope(gameContext); + + var generator = new BotGenerator(gameContext, logger); + + if (configuration.ResetBots) + { + try + { + var deleted = await generator.DeleteAllBotsAsync().ConfigureAwait(false); + logger.LogInformation("Reset requested: purged {Deleted} bot account(s).", deleted); + + // Clear the flag (in memory and persisted) so the next restart does not purge again. + configuration.ResetBots = false; + await this.PersistConfigurationAsync(gameContext, configuration, logger).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to reset the bot population."); + } + } + + try + { + // Generate the persistent bot population if it is not there yet (idempotent). + var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false); + if (created > 0) + { + logger.LogInformation("Generated {Created} new bot account(s).", created); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate the bot population."); + } + + var charactersPerAccount = Math.Clamp( + Math.Min(configuration.MaxCharactersPerAccount, gameContext.Configuration.MaximumCharactersPerAccount), + 1, + BotConfiguration.MaxCharactersPerAccountLimit); + + var started = 0; + var total = 0; + for (var i = 1; i <= configuration.NumberOfAccounts; i++) + { + var loginName = BotGenerator.GetLoginName(i); + for (byte slot = 0; slot < charactersPerAccount; slot++) + { + total++; + try + { + if (await this._botManager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false)) + { + started++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, slot); + } + } + } + + // The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot) accounts. + foreach (var loginName in configuration.GetProofOfConceptAccounts()) + { + try + { + if (await this._botManager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false)) + { + started++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName); + } + } + + logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total); + } + + /// + public void ForceStart() + { + this._nextRunUtc = DateTime.UtcNow; + } + + /// + public object CreateDefaultConfig() + { + return CreateDefaultConfiguration(); + } + + private static BotConfiguration CreateDefaultConfiguration() + { + return new BotConfiguration(); + } + + /// + /// Persists the current configuration back to its row, so a + /// programmatic change (e.g. clearing the reset flag) survives a restart. + /// + private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger) + { + try + { + // Load the configuration through a fresh context so the PlugInConfiguration entity is tracked + // and the change is actually persisted - the in-memory cached config graph is not tracked. + using var context = gameContext.PersistenceContextProvider.CreateNewContext(); + var typeId = typeof(BotFeaturePlugIn).GUID; + var gameConfiguration = (await context.GetAsync().ConfigureAwait(false)).FirstOrDefault(); + var entity = gameConfiguration?.PlugInConfigurations.FirstOrDefault(c => c.TypeId == typeId); + if (entity is null) + { + logger.LogWarning("Could not find the bot plugin configuration row to persist."); + return; + } + + entity.SetConfiguration(configuration, gameContext.PlugInManager.CustomConfigReferenceHandler); + await context.SaveChangesAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to persist the bot plugin configuration."); + } + } +} diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs new file mode 100644 index 000000000..09b7b68c1 --- /dev/null +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -0,0 +1,214 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Linq; +using System.Threading; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.Persistence; + +/// +/// Generates and maintains the persistent population of bot accounts and their characters. +/// +/// +/// Accounts are flagged with so they can be reliably reloaded on +/// startup (instead of being regenerated) and purged on request. The account login names follow +/// a deterministic, internal scheme () which is never shown to other +/// players; the player-visible character names are realistic and unique (see ). +/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start. +/// +internal sealed class BotGenerator +{ + private const string LoginPrefix = "bot"; + private const int MinLevel = 10; + private const int MaxLevel = 80; + private const int StartMoney = 100000; + + private readonly IGameContext _gameContext; + private readonly ILogger _logger; + private readonly BotNameGenerator _nameGenerator = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The game context. + /// The logger. + public BotGenerator(IGameContext gameContext, ILogger logger) + { + this._gameContext = gameContext; + this._logger = logger; + } + + /// + /// Gets the deterministic, internal login name of the bot account with the given one-based index. + /// + /// The one-based account index. + /// The login name, e.g. bot0001 (kept within the 10 character account name limit). + public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}"; + + /// + /// Ensures that the configured number of bot accounts (each with the configured number of + /// characters) exists. Only missing accounts are created. + /// + /// The desired number of bot accounts. + /// The desired number of characters per account. + /// The cancellation token. + /// The number of accounts that were newly created. + public async ValueTask EnsureBotsAsync(int numberOfAccounts, int charactersPerAccount, CancellationToken cancellationToken = default) + { + var creatableClasses = this._gameContext.Configuration.CharacterClasses + .Where(c => c is { CanGetCreated: true, HomeMap: not null }) + .ToList(); + if (creatableClasses.Count == 0) + { + this._logger.LogWarning("No creatable character classes found - cannot generate bots."); + return 0; + } + + var perAccount = Math.Clamp( + Math.Min(charactersPerAccount, this._gameContext.Configuration.MaximumCharactersPerAccount), + 1, + BotConfiguration.MaxCharactersPerAccountLimit); + + var experienceTable = this._gameContext.ExperienceTable; + var maxLevel = Math.Min(MaxLevel, experienceTable.Length - 1); + var minLevel = Math.Clamp(MinLevel, 1, maxLevel); + + using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration); + var reservedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var created = 0; + + for (var i = 1; i <= numberOfAccounts; i++) + { + cancellationToken.ThrowIfCancellationRequested(); + var loginName = GetLoginName(i); + var existing = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false); + if (existing is not null) + { + continue; + } + + var account = context.CreateNew(); + account.LoginName = loginName; + account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); + account.IsBot = true; + account.Vault = context.CreateNew(); + + for (byte slot = 0; slot < perAccount; slot++) + { + var characterClass = creatableClasses.SelectRandom()!; + var level = Rand.NextInt(minLevel, maxLevel + 1); + var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false); + this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable); + } + + // Save per account so a single failure does not roll back already generated accounts, + // and re-runs simply resume where they left off (idempotent). + if (await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false)) + { + created++; + this._logger.LogInformation("Generated bot account '{LoginName}' with {Count} character(s).", loginName, perAccount); + } + } + + return created; + } + + /// + /// Deletes all bot accounts (and, by cascade, their characters and owned data). + /// + /// The cancellation token. + /// The number of deleted bot accounts. + public async ValueTask DeleteAllBotsAsync(CancellationToken cancellationToken = default) + { + using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration); + var deleted = 0; + const int pageSize = 100; + var skip = 0; + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + var page = (await context.GetAccountsOrderedByLoginNameAsync(skip, pageSize, cancellationToken).ConfigureAwait(false)).ToList(); + if (page.Count == 0) + { + break; + } + + var bots = page.Where(a => a.IsBot).ToList(); + foreach (var bot in bots) + { + if (await context.DeleteAsync(bot).ConfigureAwait(false)) + { + deleted++; + } + } + + // Commit this page's deletions before paging on, so the ordering used by the next query + // reflects the removals: the non-bot accounts of this page now occupy [skip, skip + nonBotCount). + if (bots.Count > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + skip += page.Count - bots.Count; + } + + return deleted; + } + + private static byte[] CreateDefaultKeyConfiguration() + { + // Mirrors CreateCharacterAction: bind Q to the healing potion and W to the mana potion, + // leave E and R unbound. An all-zero blob would otherwise bind the apple (a heal) to all slots. + const byte healingPotion = 1; + const byte manaPotion = 4; + const byte unbound = 0xFF; + + var keyConfiguration = new byte[30]; + keyConfiguration[21] = healingPotion; // Q + keyConfiguration[22] = manaPotion; // W + keyConfiguration[23] = unbound; // E + keyConfiguration[25] = unbound; // R + return keyConfiguration; + } + + private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable) + { + var character = context.CreateNew(); + character.CharacterClass = characterClass; + character.Name = name; + character.CharacterSlot = slot; + character.CreateDate = DateTime.UtcNow; + character.KeyConfiguration = CreateDefaultKeyConfiguration(); + + foreach (var attribute in characterClass.StatAttributes.Select(a => context.CreateNew(a.Attribute, a.BaseValue))) + { + character.Attributes.Add(attribute); + } + + character.CurrentMap = characterClass.HomeMap; + var spawnGate = character.CurrentMap!.ExitGates.Where(g => g.IsSpawnGate).SelectRandom(); + if (spawnGate is not null) + { + character.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2); + character.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2); + } + + var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level); + levelAttribute.Value = level; + character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)]; + character.LevelUpPoints = (int)((level - 1) + * characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue); + + character.Inventory = context.CreateNew(); + character.Inventory.Money = StartMoney; + + account.Characters.Add(character); + } +} diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs new file mode 100644 index 000000000..4e1fba8dd --- /dev/null +++ b/src/GameLogic/Bots/BotManager.cs @@ -0,0 +1,130 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Collections.Concurrent; +using System.Linq; +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// Manages the lifecycle of server-side bots. +/// +/// +/// A bot reuses the connection-less together with its MU Helper AI, +/// but is spawned in a fully standalone way: the account is loaded fresh in the bot's own +/// persistence context (see ), so there is no +/// cross-context attach of entities owned by another player - which is the root cause of the +/// known data-corruption issue of the /offlevel handover. Because each bot drives a +/// distinct character in its own context, several bots can animate different characters of the +/// same account at once (the shared account row is only attached, never modified). +/// +public sealed class BotManager +{ + private readonly ConcurrentDictionary _bots = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets a snapshot of the currently active bots. + /// + public IReadOnlyCollection Bots => this._bots.Values.ToList(); + + /// + /// Spawns a bot which drives a specific character of the given account. + /// + /// The game context. + /// The login name of an existing account. + /// The character slot to drive; null drives the first character by slot. + /// true if a bot was started; false if it could not be started or was already active. + public async ValueTask SpawnBotAsync(IGameContext gameContext, string loginName, byte? characterSlot = null) + { + if (string.IsNullOrWhiteSpace(loginName)) + { + return false; + } + + var bot = new BotPlayer(gameContext); + var added = false; + string? key = null; + try + { + // Load the account through the bot's OWN persistence context (no cross-context attach). + var account = await bot.PersistenceContext.GetAccountByLoginNameAsync(loginName).ConfigureAwait(false); + var character = characterSlot is { } slot + ? account?.Characters.FirstOrDefault(c => c.CharacterSlot == slot) + : account?.Characters.OrderBy(c => c.CharacterSlot).FirstOrDefault(); + if (account is null || character is null) + { + bot.Logger.LogWarning("Bot account '{LoginName}' (slot {Slot}) could not be loaded or has no character.", loginName, characterSlot); + await bot.DisposeAsync().ConfigureAwait(false); + return false; + } + + key = GetKey(loginName, character.CharacterSlot); + if (!this._bots.TryAdd(key, bot)) + { + // Already animating this character. + await bot.DisposeAsync().ConfigureAwait(false); + return false; + } + + added = true; + + // Provide AI settings so the bot actually hunts (a missing config means a single-tile range). + bot.MuHelperSettings = new BotMuHelperSettings(); + + if (!await bot.InitializeAsync(account, character).ConfigureAwait(false)) + { + await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false); + return false; + } + + bot.Logger.LogInformation("Bot started for account '{LoginName}', character '{Character}'.", loginName, character.Name); + return true; + } + catch (Exception ex) + { + bot.Logger.LogError(ex, "Failed to spawn bot for account '{LoginName}' (slot {Slot}).", loginName, characterSlot); + if (added && key is not null) + { + await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false); + } + else + { + await bot.DisposeAsync().ConfigureAwait(false); + } + + return false; + } + } + + /// + /// Stops and removes all currently active bots. + /// + /// The task. + public async ValueTask StopAllAsync() + { + foreach (var key in this._bots.Keys.ToList()) + { + if (this._bots.TryRemove(key, out var bot)) + { + try + { + await bot.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + bot.Logger.LogError(ex, "Error while stopping bot '{Key}'.", key); + } + } + } + } + + private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}"; + + private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot) + { + this._bots.TryRemove(key, out _); + await bot.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Bots/BotNameGenerator.cs b/src/GameLogic/Bots/BotNameGenerator.cs new file mode 100644 index 000000000..2cd70f08f --- /dev/null +++ b/src/GameLogic/Bots/BotNameGenerator.cs @@ -0,0 +1,91 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Threading; +using MUnique.OpenMU.Persistence; + +/// +/// Generates pronounceable, realistic-looking character names for bots. +/// +/// +/// Names are built procedurally from syllables so the generator scales to thousands of unique +/// names while still looking like names a real player might pick. Every generated name satisfies +/// the default character name rules (3-10 alphanumeric characters), so bots blend in with players; +/// the bot nature is tracked by , never by the name. +/// +internal sealed class BotNameGenerator +{ + private static readonly string[] Starts = + { + "Dra", "Kar", "Mil", "Tho", "Zan", "Bel", "Gor", "Vyn", "Ael", "Mor", + "Run", "Syl", "Tor", "Kra", "Fen", "Lyr", "Nyx", "Ori", "Var", "Eld", + "Bro", "Cyn", "Dar", "Hal", "Ith", "Jor", "Kae", "Lor", "Mag", "Nor", + "Pyr", "Rha", "Ser", "Ulr", "Wyn", "Xan", "Yor", "Zel", "Ari", "Cae", + }; + + private static readonly string[] Middles = + { + string.Empty, string.Empty, string.Empty, + "a", "e", "i", "o", "ia", "ae", "an", "or", "el", "yn", "ar", + }; + + private static readonly string[] Ends = + { + "dor", "lin", "rik", "gar", "wyn", "ric", "mir", "dan", "eth", "ron", + "ana", "ella", "ix", "ael", "oth", "ara", "une", "is", "ius", "wen", + }; + + private readonly IRandomizer _randomizer = Rand.GetRandomizer(); + + /// + /// Generates a name which is not yet used, neither within this run nor in the database. + /// + /// The context used to check name availability. + /// The set of names already handed out in this run; the returned name is added to it. + /// The cancellation token. + /// A unique, valid character name. + public async ValueTask GenerateUniqueAsync(IPlayerContext context, ISet reserved, CancellationToken cancellationToken = default) + { + for (var attempt = 0; attempt < 500; attempt++) + { + var name = this.BuildName(attempt); + if (!reserved.Add(name)) + { + continue; + } + + var existing = await context.GetAccountByCharacterNameAsync(name, cancellationToken).ConfigureAwait(false); + if (existing is null) + { + return name; + } + } + + throw new InvalidOperationException("Could not generate a unique bot character name after many attempts."); + } + + private string BuildName(int attempt) + { + var start = Starts.SelectRandom(this._randomizer)!; + var middle = Middles.SelectRandom(this._randomizer)!; + var end = Ends.SelectRandom(this._randomizer)!; + var name = start + middle + end; + + // Once the simple name space gets crowded, append a digit to keep finding free names + // without ever exceeding the 10 character limit. + if (attempt > 30) + { + var suffix = (attempt % 10).ToString(); + name = (name.Length >= 10 ? name[..9] : name) + suffix; + } + else if (name.Length > 10) + { + name = name[..10]; + } + + return char.ToUpperInvariant(name[0]) + name[1..].ToLowerInvariant(); + } +} diff --git a/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs new file mode 100644 index 000000000..f6a71e046 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + using Microsoft.EntityFrameworkCore; + using Microsoft.EntityFrameworkCore.Infrastructure; + using Microsoft.EntityFrameworkCore.Migrations; + + /// + [DbContext(typeof(EntityDataContext))] + [Migration("20260627120000_AddAccountIsBot")] + public partial class AddAccountIsBot : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsBot", + schema: "data", + table: "Account", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsBot", + schema: "data", + table: "Account"); + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs index 5c73befdd..fde7193ef 100644 --- a/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs +++ b/src/Persistence/EntityFramework/Migrations/EntityDataContextModelSnapshot.cs @@ -35,6 +35,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasColumnType("text"); + b.Property("IsBot") + .HasColumnType("boolean"); + b.Property("IsTemplate") .HasColumnType("boolean"); From 2dbbcbfe3dfe4c15f087f94a9b8d7e85eef52722 Mon Sep 17 00:00:00 2001 From: nolt Date: Sat, 27 Jun 2026 23:28:03 +0200 Subject: [PATCH 03/60] feat(bots): cross-map warp to level-appropriate maps (phase 4) When the current map has no monster spawns within the bot's full- experience, reasonably safe level band, the navigator warps the bot to a random enterable map whose monsters do fit its level, arriving at the destination map's safezone and then walking out to a hunting ground like a real player. A cooldown prevents bouncing between maps. This stops over-levelled bots from grinding starter-map monsters far below their level for almost no experience. Known limitation: the map change is not yet persisted (the assignment of the shared game-configuration map entity is not tracked by the bot's own context), so after a restart a bot reloads on its home map and warps again. Character progress (level, experience, items) is unaffected. --- src/GameLogic/Bots/BotNavigator.cs | 72 ++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index ba6638da9..c6a59898e 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions; using MUnique.OpenMU.Pathfinding; /// @@ -54,6 +55,9 @@ internal sealed class BotNavigator : AsyncDisposable private const int MaxPointPickAttempts = 25; + /// Minimum time between two cross-map warps, so a bot does not bounce between maps. + private static readonly TimeSpan WarpCooldown = TimeSpan.FromSeconds(60); + /// /// A shared full-map path finder for long-distance travel. The pooled path finder uses a small scoped /// grid (it rejects start/end further apart than ~16 tiles), so it can only do local movement; this one @@ -72,6 +76,7 @@ internal sealed class BotNavigator : AsyncDisposable private int _isEvaluating; private Point _destination; private bool _hasDestination; + private DateTime _lastWarpUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -191,6 +196,25 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._emptyGroundSince = null; + // If the current map offers no level-appropriate monsters, warp to a map that does, so the bot + // earns useful experience instead of grinding monsters far below (or above) its level. It warps + // to the destination map's safezone and then walks out to a hunting ground like a real player. + var botLevel = this.GetBotLevel(); + if (DateTime.UtcNow - this._lastWarpUtc >= WarpCooldown + && !MapHasIdealBand(map.Definition, botLevel) + && this.TryPickTargetMap(botLevel, out var targetGate, out var targetMap)) + { + this._lastWarpUtc = DateTime.UtcNow; + this._hasDestination = false; + this._player.Logger.LogInformation( + "Bot {Character} (level {Level}) warping to map {Map} for a better level match.", + this._player.Name, + botLevel, + targetMap.Name); + await this._player.WarpToAsync(targetGate).ConfigureAwait(false); + return; + } + if (this.TryPickHuntingGround(map, out var ground, out var groundLevel)) { this._destination = ground; @@ -265,6 +289,54 @@ private static PathFinder CreateTravelPathFinder() private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); + /// + /// Determines whether the map has at least one monster spawn within the bot's full-experience, + /// reasonably safe level band - i.e. whether it is worth hunting on for that level. + /// + private static bool MapHasIdealBand(GameMapDefinition mapDefinition, int botLevel) + { + return mapDefinition.MonsterSpawns + .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + .Select(a => GetMonsterLevel(a.MonsterDefinition!)) + .Any(level => level > 0 && level >= botLevel - FullExpLowerHeadroom && level <= botLevel + SafeUpperHeadroom); + } + + /// + /// Picks a random enterable map (other than the current one) whose monsters fit the bot's level, + /// together with one of its safezone spawn gates to warp to. + /// + private bool TryPickTargetMap(int botLevel, out ExitGate gate, out GameMapDefinition mapDefinition) + { + gate = default!; + mapDefinition = default!; + var currentDefinition = this._player.CurrentMap?.Definition; + var candidates = new List<(GameMapDefinition Map, ExitGate Gate)>(); + foreach (var candidate in this._player.GameContext.Configuration.Maps) + { + if (ReferenceEquals(candidate, currentDefinition) + || !MapHasIdealBand(candidate, botLevel) + || candidate.TryGetRequirementError(this._player, out _)) + { + continue; + } + + if (candidate.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } spawnGate) + { + candidates.Add((candidate, spawnGate)); + } + } + + if (candidates.Count == 0) + { + return false; + } + + var chosen = candidates[Rand.NextInt(0, candidates.Count)]; + gate = chosen.Gate; + mapDefinition = chosen.Map; + return true; + } + /// /// Picks a hunting ground point from the map's monster spawn areas, preferring monsters that suit the /// bot's level: ideally within [level-10, level+5] (full experience, safe); otherwise the closest band From ac905fca5175d69116b62696819b55c12e22e3a8 Mon Sep 17 00:00:00 2001 From: nolt Date: Sun, 28 Jun 2026 00:35:53 +0200 Subject: [PATCH 04/60] feat(bots): equip + distribute stats, hunt survivable monsters (phase 5) Generated bots were created naked with unspent level-up points, so a high-level bot fought with level-1 base stats and no gear and died instantly without earning any experience. - Spend the level-up points at generation (half into vitality for health, half into the class's main damage stat), so a bot actually has the combat power of its level. - Equip a basic, class-appropriate weapon and matching armor set (Small Axe + Leather, Skull Staff + Pad, Short Bow + arrows + Vine), each piece in its correct equipment slot. - Recalibrate the navigator: a monster is far tougher than a character of the same level, so bots now hunt monsters at roughly half their own level (tunable via SafeMonsterFactor) and warp to the map offering the strongest monsters they can still safely handle. Result on the live lab: deaths dropped by roughly an order of magnitude and the bots gain experience instead of dying in a loop. --- src/GameLogic/Bots/BotGenerator.cs | 98 +++++++++++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 124 ++++++++++++++++------------- 2 files changed, 168 insertions(+), 54 deletions(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 09b7b68c1..bc3ed1044 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -205,10 +205,108 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)]; character.LevelUpPoints = (int)((level - 1) * characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue); + DistributeStatPoints(character); character.Inventory = context.CreateNew(); character.Inventory.Money = StartMoney; + this.EquipStarterGear(context, character, level); account.Characters.Add(character); } + + /// + /// Spends the character's level-up points, so a high-level bot actually has high-level stats. + /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and + /// damage) and die instantly. Half of the points go into vitality (health/survival) and half into + /// the class's main damage stat. + /// + private static void DistributeStatPoints(Character character) + { + var points = character.LevelUpPoints; + if (points <= 0) + { + return; + } + + var mainStat = GetMainDamageStat(character); + var vitality = character.Attributes.FirstOrDefault(a => a.Definition == Stats.BaseVitality); + if (mainStat is null || vitality is null) + { + return; + } + + var toVitality = points / 2; + vitality.Value += toVitality; + mainStat.Value += points - toVitality; + character.LevelUpPoints = 0; + } + + private static StatAttribute? GetMainDamageStat(Character character) + { + return character.Attributes + .Where(a => a.Definition == Stats.BaseStrength + || a.Definition == Stats.BaseAgility + || a.Definition == Stats.BaseEnergy + || a.Definition == Stats.BaseLeadership) + .OrderByDescending(a => a.Value) + .FirstOrDefault(); + } + + /// + /// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test + /// account gear), so it is not naked and punching with its fists. The item level scales modestly + /// with the bot level for a bit more defense/damage without raising the equip requirements too high. + /// + private void EquipStarterGear(IPlayerContext context, Character character, int level) + { + var inventory = character.Inventory!; + var itemLevel = (byte)Math.Clamp(level / 20, 0, 4); + var mainStat = GetMainDamageStat(character)?.Definition; + + // The armor set is identified by the item NUMBER (5 = Leather, 2 = Pad, 10 = Vine), while the + // item GROUP is the equipment type (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots). Weapons use + // their own group (1 axe, 4 bow, 5 staff) with number 0. + byte armorSet; + if (mainStat == Stats.BaseEnergy) + { + // Caster: Skull Staff + Pad set. + this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 5, 0, itemLevel); + armorSet = 2; + } + else if (mainStat == Stats.BaseAgility) + { + // Archer: Short Bow + arrows + Vine set. + this.AddEquippedItem(context, inventory, InventoryConstants.RightHandSlot, 4, 0, itemLevel); + this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 4, 15, 0, 255); + armorSet = 10; + } + else + { + // Melee (strength / leadership): Small Axe + Leather set. + this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 1, 0, itemLevel); + armorSet = 5; + } + + this.AddEquippedItem(context, inventory, InventoryConstants.HelmSlot, 7, armorSet, itemLevel); + this.AddEquippedItem(context, inventory, InventoryConstants.ArmorSlot, 8, armorSet, itemLevel); + this.AddEquippedItem(context, inventory, InventoryConstants.PantsSlot, 9, armorSet, itemLevel); + this.AddEquippedItem(context, inventory, InventoryConstants.GlovesSlot, 10, armorSet, itemLevel); + this.AddEquippedItem(context, inventory, InventoryConstants.BootsSlot, 11, armorSet, itemLevel); + } + + private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, byte slot, int group, int number, byte itemLevel, byte? durability = null) + { + var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number); + if (definition is null) + { + return; + } + + var item = context.CreateNew(); + item.Definition = definition; + item.Level = itemLevel; + item.Durability = durability ?? definition.Durability; + item.ItemSlot = slot; + inventory.Items.Add(item); + } } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index c6a59898e..c482f2a60 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -23,11 +23,19 @@ internal sealed class BotNavigator : AsyncDisposable private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); - /// Monsters up to this many levels below the bot still grant full experience (see ). - private const int FullExpLowerHeadroom = 10; + /// + /// The strongest monster a bot should fight, as a fraction of its own level. In MU a monster is far + /// tougher than a character of the same level (a well-geared level 400 only just manages the ~140 + /// level monsters of the hardest map), so bots target monsters well below their own level to survive + /// while still earning experience. Tunable; lower = safer/slower, higher = more experience/deadlier. + /// + private const float SafeMonsterFactor = 0.5f; - /// Preferred upper bound: monsters a few levels above the bot are still reasonably safe to fight. - private const int SafeUpperHeadroom = 5; + /// The minimum monster level a bot will hunt, so very low-level bots still find targets. + private const int MinHuntCap = 3; + + /// A bot only warps to another map if it offers monsters at least this many levels stronger (avoids hopping for tiny gains). + private const int WarpImprovementMargin = 3; /// Width of the level band of areas we randomize between, so bots don't all stack on one spot. private const int BandWidth = 3; @@ -196,21 +204,21 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._emptyGroundSince = null; - // If the current map offers no level-appropriate monsters, warp to a map that does, so the bot - // earns useful experience instead of grinding monsters far below (or above) its level. It warps - // to the destination map's safezone and then walks out to a hunting ground like a real player. + // Warp to the map that offers the strongest monsters this bot can still safely handle, so it + // earns the best experience without being slaughtered. It arrives at the destination safezone + // and walks out to a hunting ground like a real player. var botLevel = this.GetBotLevel(); if (DateTime.UtcNow - this._lastWarpUtc >= WarpCooldown - && !MapHasIdealBand(map.Definition, botLevel) - && this.TryPickTargetMap(botLevel, out var targetGate, out var targetMap)) + && this.TryPickBetterMap(botLevel, out var targetGate, out var targetMap, out var targetLevel)) { this._lastWarpUtc = DateTime.UtcNow; this._hasDestination = false; this._player.Logger.LogInformation( - "Bot {Character} (level {Level}) warping to map {Map} for a better level match.", + "Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).", this._player.Name, botLevel, - targetMap.Name); + targetMap.Name, + targetLevel); await this._player.WarpToAsync(targetGate).ConfigureAwait(false); return; } @@ -289,52 +297,67 @@ private static PathFinder CreateTravelPathFinder() private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); - /// - /// Determines whether the map has at least one monster spawn within the bot's full-experience, - /// reasonably safe level band - i.e. whether it is worth hunting on for that level. - /// - private static bool MapHasIdealBand(GameMapDefinition mapDefinition, int botLevel) + /// The highest monster level a bot of the given level should fight (see ). + private static int GetHuntCap(int botLevel) => Math.Max(MinHuntCap, (int)(botLevel * SafeMonsterFactor)); + + /// The strongest monster level on the map which is still at or below the safe cap; 0 if none. + private static int BestHuntableLevel(GameMapDefinition mapDefinition, int cap) { - return mapDefinition.MonsterSpawns - .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) - .Select(a => GetMonsterLevel(a.MonsterDefinition!)) - .Any(level => level > 0 && level >= botLevel - FullExpLowerHeadroom && level <= botLevel + SafeUpperHeadroom); + var best = 0; + foreach (var area in mapDefinition.MonsterSpawns) + { + if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + { + continue; + } + + var level = GetMonsterLevel(area.MonsterDefinition!); + if (level > 0 && level <= cap && level > best) + { + best = level; + } + } + + return best; } /// - /// Picks a random enterable map (other than the current one) whose monsters fit the bot's level, - /// together with one of its safezone spawn gates to warp to. + /// Picks the enterable map (other than the current one) that offers the strongest monsters the bot can + /// still safely handle, if it is meaningfully better than the current map, with one of its safezone gates. /// - private bool TryPickTargetMap(int botLevel, out ExitGate gate, out GameMapDefinition mapDefinition) + private bool TryPickBetterMap(int botLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) { gate = default!; mapDefinition = default!; - var currentDefinition = this._player.CurrentMap?.Definition; - var candidates = new List<(GameMapDefinition Map, ExitGate Gate)>(); + monsterLevel = 0; + + var cap = GetHuntCap(botLevel); + var current = this._player.CurrentMap?.Definition; + var threshold = (current is null ? 0 : BestHuntableLevel(current, cap)) + WarpImprovementMargin; + foreach (var candidate in this._player.GameContext.Configuration.Maps) { - if (ReferenceEquals(candidate, currentDefinition) - || !MapHasIdealBand(candidate, botLevel) - || candidate.TryGetRequirementError(this._player, out _)) + if (ReferenceEquals(candidate, current)) { continue; } - if (candidate.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } spawnGate) + var best = BestHuntableLevel(candidate, cap); + if (best < threshold || candidate.TryGetRequirementError(this._player, out _)) { - candidates.Add((candidate, spawnGate)); + continue; } - } - if (candidates.Count == 0) - { - return false; + if (candidate.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } spawnGate) + { + gate = spawnGate; + mapDefinition = candidate; + monsterLevel = best; + threshold = best + 1; // keep only a strictly-stronger map after this one + } } - var chosen = candidates[Rand.NextInt(0, candidates.Count)]; - gate = chosen.Gate; - mapDefinition = chosen.Map; - return true; + return mapDefinition is not null; } /// @@ -347,7 +370,7 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL ground = default; groundLevel = 0; - var botLevel = this.GetBotLevel(); + var cap = GetHuntCap(this.GetBotLevel()); var candidates = map.Definition.MonsterSpawns .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!))) @@ -358,26 +381,19 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL return false; } - var ideal = candidates.Where(x => x.Level >= botLevel - FullExpLowerHeadroom && x.Level <= botLevel + SafeUpperHeadroom).ToList(); + // Prefer the strongest monsters the bot can safely handle (best experience); if none on this map + // are within the safe cap, fall back to the weakest available (the bot should warp away anyway). + var safe = candidates.Where(x => x.Level <= cap).ToList(); List<(MonsterSpawnArea Area, int Level)> band; - if (ideal.Count > 0) + if (safe.Count > 0) { - var top = ideal.Max(x => x.Level); - band = ideal.Where(x => x.Level >= top - BandWidth).ToList(); + var top = safe.Max(x => x.Level); + band = safe.Where(x => x.Level >= top - BandWidth).ToList(); } else { - var below = candidates.Where(x => x.Level < botLevel - FullExpLowerHeadroom).ToList(); - if (below.Count > 0) - { - var top = below.Max(x => x.Level); - band = below.Where(x => x.Level >= top - BandWidth).ToList(); - } - else - { - var bottom = candidates.Min(x => x.Level); - band = candidates.Where(x => x.Level <= bottom + BandWidth).ToList(); - } + var bottom = candidates.Min(x => x.Level); + band = candidates.Where(x => x.Level <= bottom + BandWidth).ToList(); } // Weight the choice by spawn quantity and proximity, so the bot prefers nearby, dense grounds From d286363b9e1d1b43085aa057f03e3497ba30b055 Mon Sep 17 00:00:00 2001 From: nolt Date: Sun, 28 Jun 2026 10:16:34 +0200 Subject: [PATCH 05/60] fix(bots): keep bots alive and moving with healing, survivable gear and robust navigation - pick a class-correct weapon by stat archetype (bow for agility, staff for energy, melee otherwise) instead of letting every class end up with a Small Axe, and choose an armor set the class is actually qualified to wear - carry a stack of healing potions and top it up at runtime, so the offline healing handler has something to drink; bots were dying with no way to heal - stop bots standing in town: aim the combat centre at the destination while in a safezone and walk out, rather than swinging at monsters they cannot damage - recover from wedged walks and unreachable hunting grounds via a watchdog and an immediate re-pick of a nearer, reachable ground - only warp once a bot reaches level 30, to avoid sending low-level bots to maps far above their level --- src/GameLogic/Bots/BotGenerator.cs | 161 +++++++++++++++++++----- src/GameLogic/Bots/BotNavigator.cs | 195 +++++++++++++++++++++++------ 2 files changed, 291 insertions(+), 65 deletions(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index bc3ed1044..32a550ae6 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -9,6 +9,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using Microsoft.Extensions.Logging; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.Persistence; @@ -30,6 +31,27 @@ internal sealed class BotGenerator private const int MaxLevel = 80; private const int StartMoney = 100000; + /// Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp. + private const byte StarterItemLevel = 6; + + /// Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear). + private const byte MaxMeleeGroup = 3; + + /// Item group of bows (need ammunition). + private const byte BowGroup = 4; + + /// Item group of staves/sticks (casters). + private const byte StaffGroup = 5; + + /// Item group of body armor; its item number identifies the armor set. + private const byte ArmorGroup = 8; + + /// + /// Armor set numbers tried in thematic order; the first the class is qualified for (by its chest piece) + /// is used: 5 Leather (warriors), 2 Pad (wizards), 10 Vine (elves), 39 Mistery (summoners), then fallbacks. + /// + private static readonly byte[] ArmorSetCandidates = { 5, 2, 10, 39, 6, 0, 4, 8 }; + private readonly IGameContext _gameContext; private readonly ILogger _logger; private readonly BotNameGenerator _nameGenerator = new(); @@ -209,7 +231,7 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam character.Inventory = context.CreateNew(); character.Inventory.Money = StartMoney; - this.EquipStarterGear(context, character, level); + this.EquipStarterGear(context, character); account.Characters.Add(character); } @@ -257,56 +279,135 @@ private static void DistributeStatPoints(Character character) /// account gear), so it is not naked and punching with its fists. The item level scales modestly /// with the bot level for a bit more defense/damage without raising the equip requirements too high. /// - private void EquipStarterGear(IPlayerContext context, Character character, int level) + private void EquipStarterGear(IPlayerContext context, Character character) { var inventory = character.Inventory!; - var itemLevel = (byte)Math.Clamp(level / 20, 0, 4); - var mainStat = GetMainDamageStat(character)?.Definition; - - // The armor set is identified by the item NUMBER (5 = Leather, 2 = Pad, 10 = Vine), while the - // item GROUP is the equipment type (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots). Weapons use - // their own group (1 axe, 4 bow, 5 staff) with number 0. - byte armorSet; - if (mainStat == Stats.BaseEnergy) + var characterClass = character.CharacterClass!; + + // Data-driven so every class gets gear it is actually QUALIFIED to wear (a Dark Lord must never + // end up in a Pad/wizard set). We pick the most basic options (lowest DropLevel) the class can use: + // - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff), + // - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set, + // and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots). + // Choose the weapon type by the class's intrinsic stats: archers (agility) get a bow, pure casters + // (energy) a staff, everyone else (warriors and the Magic Gladiator hybrid) a melee blade. The Small + // Axe is qualified for almost every class, so without this casters and archers would all end up with one. + float ClassStat(AttributeDefinition attribute) + => characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f; + var strength = ClassStat(Stats.BaseStrength); + var agility = ClassStat(Stats.BaseAgility); + var energy = ClassStat(Stats.BaseEnergy); + Func isPreferredWeapon; + if (agility > strength && agility > energy) { - // Caster: Skull Staff + Pad set. - this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 5, 0, itemLevel); - armorSet = 2; + isPreferredWeapon = d => d.Group == BowGroup; } - else if (mainStat == Stats.BaseAgility) + else if (energy > strength) { - // Archer: Short Bow + arrows + Vine set. - this.AddEquippedItem(context, inventory, InventoryConstants.RightHandSlot, 4, 0, itemLevel); - this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 4, 15, 0, 255); - armorSet = 10; + isPreferredWeapon = d => d.Group == StaffGroup; } else { - // Melee (strength / leadership): Small Axe + Leather set. - this.AddEquippedItem(context, inventory, InventoryConstants.LeftHandSlot, 1, 0, itemLevel); - armorSet = 5; + isPreferredWeapon = d => d.Group <= MaxMeleeGroup; + } + + var weapon = this._gameContext.Configuration.Items + .Where(d => isPreferredWeapon(d) && d.QualifiedCharacters.Contains(characterClass)) + .MinBy(d => d.DropLevel) + ?? this._gameContext.Configuration.Items + .Where(d => d.Group <= StaffGroup && d.QualifiedCharacters.Contains(characterClass)) + .MinBy(d => d.DropLevel); + if (weapon is not null) + { + if (weapon.Group == BowGroup) + { + // Bows need ammunition; the arrows go into the left hand. + this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.RightHandSlot, weapon); + this.AddAmmunition(context, inventory); + } + else + { + this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon); + } + } + + // Choose a thematically appropriate armor set the class can wear, tried in order (warriors -> Leather, + // wizards -> Pad, elves -> Vine, summoners -> Mistery, then fallbacks). Each piece is added only if the + // class is qualified for it, so e.g. the Magic Gladiator keeps the set but skips the helm it can't wear. + foreach (var set in ArmorSetCandidates) + { + if (this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == ArmorGroup && d.Number == set) is not { } chest + || !chest.QualifiedCharacters.Contains(characterClass)) + { + continue; + } + + this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.HelmSlot, 7, set); + this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set); + this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set); + this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set); + this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set); + break; } - this.AddEquippedItem(context, inventory, InventoryConstants.HelmSlot, 7, armorSet, itemLevel); - this.AddEquippedItem(context, inventory, InventoryConstants.ArmorSlot, 8, armorSet, itemLevel); - this.AddEquippedItem(context, inventory, InventoryConstants.PantsSlot, 9, armorSet, itemLevel); - this.AddEquippedItem(context, inventory, InventoryConstants.GlovesSlot, 10, armorSet, itemLevel); - this.AddEquippedItem(context, inventory, InventoryConstants.BootsSlot, 11, armorSet, itemLevel); + this.AddHealthPotions(context, inventory); } - private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, byte slot, int group, int number, byte itemLevel, byte? durability = null) + private void AddHealthPotions(IPlayerContext context, ItemStorage inventory) + { + // A stack of Large Healing Potions so the offline HealingHandler has something to drink. The + // BotNavigator tops this up at runtime, so the bot never runs dry. Durability holds the stack count. + var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == 3); + if (potion is null) + { + return; + } + + var item = context.CreateNew(); + item.Definition = potion; + item.Durability = 255; + item.ItemSlot = InventoryConstants.EquippableSlotsCount; // first backpack slot + inventory.Items.Add(item); + } + + private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number) { var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number); - if (definition is null) + if (definition is null || !definition.QualifiedCharacters.Contains(characterClass)) + { + return; + } + + this.AddEquippedItem(context, inventory, characterClass, slot, definition); + } + + private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition) + { + if (!definition.QualifiedCharacters.Contains(characterClass)) { return; } var item = context.CreateNew(); item.Definition = definition; - item.Level = itemLevel; - item.Durability = durability ?? definition.Durability; + item.Level = StarterItemLevel; + item.Durability = definition.Durability; item.ItemSlot = slot; inventory.Items.Add(item); } + + private void AddAmmunition(IPlayerContext context, ItemStorage inventory) + { + var arrows = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 4 && d.Number == 15); + if (arrows is null) + { + return; + } + + var item = context.CreateNew(); + item.Definition = arrows; + item.Durability = 255; + item.ItemSlot = InventoryConstants.LeftHandSlot; + inventory.Items.Add(item); + } } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index c482f2a60..910cd7d1a 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Threading; +using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Offline; @@ -37,12 +38,30 @@ internal sealed class BotNavigator : AsyncDisposable /// A bot only warps to another map if it offers monsters at least this many levels stronger (avoids hopping for tiny gains). private const int WarpImprovementMargin = 3; + /// + /// Below this level a bot never warps: it stays on its class starting map (e.g. elves in Noria, + /// summoners in Elvenland), so the newbie maps stay populated instead of everyone drifting to one map. + /// + private const int MinWarpLevel = 30; + /// Width of the level band of areas we randomize between, so bots don't all stack on one spot. private const int BandWidth = 3; /// Range (tiles) around the origin that counts as "at the hunting ground". private const int HuntingRange = 6; + /// Item group of healing potions (Apple / Small / Medium / Large all share group 14). + private const int HealPotionGroup = 14; + + /// Item number of the Large Healing Potion within . + private const int HealPotionNumber = 3; + + /// Stack size (the potion item's Durability holds the remaining count; one is spent per heal). + private const byte HealPotionStack = 255; + + /// Refill the stack once it drops below this, so the bot never runs out mid-fight. + private const int HealPotionRefillBelow = 30; + /// Number of path steps to issue per travel hop. Short hops let the bot stop and fight between hops. private const int StepsPerHop = 3; @@ -66,6 +85,12 @@ internal sealed class BotNavigator : AsyncDisposable /// Minimum time between two cross-map warps, so a bot does not bounce between maps. private static readonly TimeSpan WarpCooldown = TimeSpan.FromSeconds(60); + /// + /// If the bot's position has not changed for this long it is considered stuck (a wedged walk, or a + /// monster it can't reach). The navigator then forces a fresh destination so the bot gets going again. + /// + private static readonly TimeSpan StuckTimeout = TimeSpan.FromSeconds(8); + /// /// A shared full-map path finder for long-distance travel. The pooled path finder uses a small scoped /// grid (it rejects start/end further apart than ~16 tiles), so it can only do local movement; this one @@ -85,6 +110,8 @@ internal sealed class BotNavigator : AsyncDisposable private Point _destination; private bool _hasDestination; private DateTime _lastWarpUtc = DateTime.MinValue; + private Point _lastPosition; + private DateTime _lastMoveUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -164,42 +191,87 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // Make sure the bot always carries healing potions. Without them the offline HealingHandler has + // nothing to drink and the bot dies to any sustained damage. This also tops up already-generated + // bots at runtime, so the fix applies without regenerating them. + await this.EnsureHealthPotionsAsync().ConfigureAwait(false); + // Keep the combat centre on the bot's current position, so the combat handler always engages // monsters right next to the bot - both at the hunting ground and while travelling through hostile // territory (self-defence). This is what keeps a travelling bot alive. The travel destination is // tracked separately in _destination. - this._player.HuntingOrigin = this._player.Position; + var inSafezone = map.Terrain.SafezoneMap[this._player.Position.X, this._player.Position.Y]; - // A walk (travel hop or local combat move) is already in progress. - if (this._player.IsWalking) + // Combat centre: normally the bot's own position (self-defence while travelling and at the hunting + // ground). But while inside the safezone we aim it at the destination, so the combat handler does NOT + // make the bot stand in town swinging at monsters just outside (which it can't damage from a safezone) - + // it should simply walk out of town instead. + this._player.HuntingOrigin = inSafezone && this._hasDestination ? this._destination : this._player.Position; + + // Watchdog bookkeeping: remember when the bot last actually changed position. + if (this._player.Position != this._lastPosition) { - return; + this._lastPosition = this._player.Position; + this._lastMoveUtc = DateTime.UtcNow; } var monstersNearby = map.GetAttackablesInRange(this._player.Position, TravelStopRange) .OfType() .Count(m => m.IsAlive && !m.IsAtSafezone()); - // Monsters right here: stop and let the combat handler fight them (don't walk away into more danger). - if (monstersNearby > 0) + // The bot counts as stuck only when it has been frozen on the spot with NOTHING to fight - i.e. a + // wedged walk or a blocked path. A bot standing still because it is fighting nearby monsters is fine. + var stuck = monstersNearby == 0 && DateTime.UtcNow - this._lastMoveUtc > StuckTimeout; + if (stuck) + { + // Break free: pick a fresh hunting ground and walk to it now. Issuing the walk resets the wedged + // walker, and a new destination avoids re-stalling on the same blocked path. + this._lastMoveUtc = DateTime.UtcNow; + this._emptyGroundSince = null; + await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); + return; + } + + // A walk (travel hop or local combat move) is already in progress. + if (this._player.IsWalking) + { + return; + } + + // Inside the safezone the bot can't fight anyway, so it must keep walking out of town instead of + // freezing at the gate when a monster is just outside. Only stop to fight once it has left the safezone. + if (!inSafezone && monstersNearby > 0) { this._emptyGroundSince = null; return; } - // Nothing to fight here. If we still have a destination to reach, keep walking towards it. + // Nothing to fight here. If we still have a destination to reach, keep walking towards it. If the + // route turned out to be impossible (e.g. across a river), drop it and fall through to pick another + // ground in this same tick instead of standing still. if (this._hasDestination && this._player.GetDistanceTo(this._destination) > HuntingRange) { - await this.TravelTowardAsync(map, this._destination).ConfigureAwait(false); + if (!await this.TravelTowardAsync(map, this._destination).ConfigureAwait(false)) + { + // Impossible route: pick another ground now (proximity-weighted toward nearby, reachable + // ones) instead of standing still re-issuing an impossible walk. + this._emptyGroundSince = null; + await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); + } + return; } // Arrived (or no destination) and the area is empty: wait out a short grace, then pick a new ground. + // In the safezone we skip the grace so a freshly-spawned bot heads out of town straight away. this._hasDestination = false; - this._emptyGroundSince ??= DateTime.UtcNow; - if (DateTime.UtcNow - this._emptyGroundSince < EmptyGroundGrace) + if (!inSafezone) { - return; + this._emptyGroundSince ??= DateTime.UtcNow; + if (DateTime.UtcNow - this._emptyGroundSince < EmptyGroundGrace) + { + return; + } } this._emptyGroundSince = null; @@ -208,7 +280,8 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // earns the best experience without being slaughtered. It arrives at the destination safezone // and walks out to a hunting ground like a real player. var botLevel = this.GetBotLevel(); - if (DateTime.UtcNow - this._lastWarpUtc >= WarpCooldown + if (botLevel >= MinWarpLevel + && DateTime.UtcNow - this._lastWarpUtc >= WarpCooldown && this.TryPickBetterMap(botLevel, out var targetGate, out var targetMap, out var targetLevel)) { this._lastWarpUtc = DateTime.UtcNow; @@ -223,21 +296,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } - if (this.TryPickHuntingGround(map, out var ground, out var groundLevel)) - { - this._destination = ground; - this._hasDestination = true; - this._player.Logger.LogInformation( - "Bot {Character} (level {Level}) heading to hunting ground {Ground} (monster level ~{MonsterLevel}).", - this._player.Name, - this.GetBotLevel(), - ground, - groundLevel); - } - else - { - this._player.Logger.LogDebug("Bot {Character}: no reachable hunting ground found on map {Map}.", this._player.Name, map.Definition.Name); - } + await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); } /// @@ -246,7 +305,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) /// issued; the remainder is recomputed from the new position on the next tick. The safe zone is allowed /// in the path so the bot can leave town. /// - private async ValueTask TravelTowardAsync(GameMap map, Point destination) + private async ValueTask TravelTowardAsync(GameMap map, Point destination) { var position = this._player.Position; @@ -262,15 +321,11 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) TravelPathFinderLock.Release(); } - this._player.Logger.LogDebug( - "Bot {Character}: travel {From} -> dest {Dest}, pathLen={PathLen}.", - this._player.Name, - position, - destination, - path?.Count ?? -1); if (path is null || path.Count == 0) { - return; + // No route to this ground (e.g. across a river): report it so the navigator picks another one + // straight away instead of standing still re-issuing an impossible walk. + return false; } var stepsCount = Math.Min(path.Count, StepsPerHop); @@ -283,6 +338,76 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) } await this._player.WalkToAsync(steps[stepsCount - 1].To, steps).ConfigureAwait(false); + return true; + } + + /// + /// Picks a hunting ground and starts walking to it. If the chosen ground turns out to be unreachable the + /// destination is dropped, so the next call (next tick) simply picks another one. + /// + private async ValueTask PickGroundAndTravelAsync(GameMap map) + { + if (!this.TryPickHuntingGround(map, out var ground, out var groundLevel)) + { + this._hasDestination = false; + this._player.Logger.LogDebug("Bot {Character}: no hunting ground found on map {Map}.", this._player.Name, map.Definition.Name); + return; + } + + this._destination = ground; + this._hasDestination = true; + this._player.Logger.LogInformation( + "Bot {Character} (level {Level}) heading to hunting ground {Ground} (monster level ~{MonsterLevel}).", + this._player.Name, + this.GetBotLevel(), + ground, + groundLevel); + + if (!await this.TravelTowardAsync(map, ground).ConfigureAwait(false)) + { + this._hasDestination = false; + } + } + + /// + /// Ensures the bot keeps a stack of healing potions in its inventory. Creates the stack the first time + /// (covering bots generated before potions were granted) and refills it once it runs low, so the bot + /// never goes without a way to heal. + /// + private async ValueTask EnsureHealthPotionsAsync() + { + if (this._player.Inventory is not { } inventory) + { + return; + } + + var potion = inventory.Items.FirstOrDefault(i => + i.Definition?.Group == HealPotionGroup && i.Definition.Number == HealPotionNumber); + if (potion is not null) + { + if (potion.Durability < HealPotionRefillBelow) + { + potion.Durability = HealPotionStack; + } + + return; + } + + var definition = this._player.GameContext.Configuration.Items + .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == HealPotionNumber); + if (definition is null) + { + return; + } + + var item = this._player.PersistenceContext.CreateNew(); + item.Definition = definition; + item.Durability = HealPotionStack; + if (!await inventory.AddItemAsync(item).ConfigureAwait(false)) + { + // No free slot (should not happen for a bot) - drop the throw-away item again. + await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + } } private static PathFinder CreateTravelPathFinder() From 3045e06b2c5b0cd393466d2b1698521e61d4b0e2 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 2 Jul 2026 08:12:02 +0200 Subject: [PATCH 06/60] fix(bots): exempt bots from the offline PC-Cafe Zen fee Bots don't accumulate Zen fast enough to cover the periodic PC-Cafe fee, so they went bankrupt and their offline sessions stopped, leaving few bots online. Skip the fee in ZenConsumptionHandler when Account.IsBot is true. Human offline-leveling players (IsBot == false) keep paying as before. --- src/GameLogic/Offline/ZenConsumptionHandler.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/GameLogic/Offline/ZenConsumptionHandler.cs b/src/GameLogic/Offline/ZenConsumptionHandler.cs index a0487f357..bada0e478 100644 --- a/src/GameLogic/Offline/ZenConsumptionHandler.cs +++ b/src/GameLogic/Offline/ZenConsumptionHandler.cs @@ -33,6 +33,14 @@ public ZenConsumptionHandler(OfflinePlayer player) /// public async Task 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; + } + if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval) { return; From 3dbd4ecff6f9e7f09a2cc7b3a9ca2f5e3c6bfbbc Mon Sep 17 00:00:00 2001 From: Eduardo <6845999+eduardosmaniotto@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:35:47 -0300 Subject: [PATCH 07/60] bugfix: replace duplicate GUID --- src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs b/src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs index 618d0b86f..fa39cc975 100644 --- a/src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs +++ b/src/GameLogic/PlayerActions/Skills/PollutionSkillPlugIn.cs @@ -16,7 +16,7 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills; /// [PlugIn] [Display(Name = nameof(PlugInResources.PollutionSkillPlugIn_Name), Description = nameof(PlugInResources.PollutionSkillPlugIn_Description), ResourceType = typeof(PlugInResources))] -[Guid("9F4B2C1D-E7A6-4B3C-8D9E-0FAB12C3D4E5")] +[Guid("97BE0BFD-C55C-4E68-9B2D-12156825481A")] public class PollutionSkillPlugIn : IAreaSkillPlugIn { /// From 271c10d2fa26135561269e7ac1ecabe6c9e7c42b Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 7 Jul 2026 23:19:47 +0200 Subject: [PATCH 08/60] fix(bots): adapt to master offline API after merge - BotManager: OfflinePlayer.InitializeAsync now takes (loginName, characterName) and loads the account fresh (dualcontext fix from #806), instead of captured Account/Character references. - ZenConsumptionHandler.DeductZenAsync now returns bool; the bot exemption returns true (bot may continue) instead of a bare return. --- src/GameLogic/Bots/BotManager.cs | 2 +- src/GameLogic/Offline/ZenConsumptionHandler.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index 4e1fba8dd..eec3c52aa 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -73,7 +73,7 @@ public async ValueTask SpawnBotAsync(IGameContext gameContext, string logi // Provide AI settings so the bot actually hunts (a missing config means a single-tile range). bot.MuHelperSettings = new BotMuHelperSettings(); - if (!await bot.InitializeAsync(account, character).ConfigureAwait(false)) + if (!await bot.InitializeAsync(loginName, character.Name).ConfigureAwait(false)) { await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false); return false; diff --git a/src/GameLogic/Offline/ZenConsumptionHandler.cs b/src/GameLogic/Offline/ZenConsumptionHandler.cs index 503c5b34e..85f170168 100644 --- a/src/GameLogic/Offline/ZenConsumptionHandler.cs +++ b/src/GameLogic/Offline/ZenConsumptionHandler.cs @@ -39,7 +39,7 @@ public async ValueTask DeductZenAsync() // players (IsBot == false) keep paying as before. if (this._player.Account?.IsBot == true) { - return; + return true; } if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval) From 2c36430395aa8fa2066f88baff26d542bc8af9d8 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 00:40:44 +0200 Subject: [PATCH 09/60] bots: scale to 250 and add class/level-appropriate skills Scale (250 bots): the single shared long-distance pathfinder serialized navigation for all bots, so at 250 they starved and got stuck in town. Replace it with a small pooled set (SemaphoreSlim + ConcurrentBag, size clamped to the core count). Also fix loot: PickSelectItems was false, which made the pickup handler bail out before the selective Pick* flags (zen/jewels) could take effect, so bots collected nothing. Skills: bots fought with only their weapon because the offline combat picks its skill from explicit config IDs, which bots never set. Add an AutoSelectBestSkill flag to IMuHelperSettings (bots only; human MU Helper sessions keep their explicit configuration). When set, the combat AI casts the strongest learned attack skill the character can currently afford, so it scales with level and mana. BotGenerator now teaches each bot the attack skills of its own class up to a per-skill learn level derived from the skill's attack damage, and a new ICharacterLevelUpPlugIn (BotSkillProgressionPlugIn) grants further class skills as the bot levels up during play. --- src/GameLogic/Bots/BotGenerator.cs | 81 ++++++++++++++++++- src/GameLogic/Bots/BotMuHelperSettings.cs | 13 ++- src/GameLogic/Bots/BotNavigator.cs | 47 ++++++++--- .../Bots/BotSkillProgressionPlugIn.cs | 74 +++++++++++++++++ src/GameLogic/MuHelper/IMuHelperSettings.cs | 8 ++ src/GameLogic/Offline/CombatHandler.cs | 62 +++++++++++++- .../RemoteView/MuHelper/MuHelperSettings.cs | 4 + 7 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 src/GameLogic/Bots/BotSkillProgressionPlugIn.cs diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 32a550ae6..c91236b34 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -34,6 +34,14 @@ internal sealed class BotGenerator /// Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp. private const byte StarterItemLevel = 6; + /// + /// Skill-tier scaling: a class attack skill becomes learnable once the character level reaches roughly + /// its attack-damage rating times this factor. Attack damage is a monotonic proxy for the skill tier + /// within a class (e.g. Dark Wizard: Energy Ball 3 → Hellfire 120), so higher-level bots progressively + /// unlock stronger spells while low-level ones only get the basics - always only for their own class. + /// + private const double SkillLearnDamageFactor = 0.9; + /// Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear). private const byte MaxMeleeGroup = 3; @@ -106,6 +114,11 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters var reservedNames = new HashSet(StringComparer.OrdinalIgnoreCase); var created = 0; + // Build a balanced, shuffled queue of classes so the whole population is evenly split across + // all creatable classes. Independent random draws leave visible skew at this scale (e.g. 11 + // Summoners vs 4 Elves for 50 bots); the quota queue guarantees ~even counts, drawn per character. + var classQueue = BuildBalancedClassQueue(creatableClasses, numberOfAccounts * perAccount); + for (var i = 1; i <= numberOfAccounts; i++) { cancellationToken.ThrowIfCancellationRequested(); @@ -124,7 +137,7 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters for (byte slot = 0; slot < perAccount; slot++) { - var characterClass = creatableClasses.SelectRandom()!; + var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!; var level = Rand.NextInt(minLevel, maxLevel + 1); var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false); this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable); @@ -142,6 +155,30 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters return created; } + /// + /// Builds a shuffled queue of character classes with even quotas across , + /// so the generated population is balanced instead of relying on the variance of independent random + /// draws. The order is randomized so accounts do not get a predictable class pattern. + /// + private static Queue BuildBalancedClassQueue(IList classes, int total) + { + var pool = new List(total); + for (var n = 0; n < total; n++) + { + // Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes). + pool.Add(classes[n % classes.Count]); + } + + // Fisher-Yates shuffle so the balanced pool is handed out in random order. + for (var n = pool.Count - 1; n > 0; n--) + { + var j = Rand.NextInt(0, n + 1); + (pool[n], pool[j]) = (pool[j], pool[n]); + } + + return new Queue(pool); + } + /// /// Deletes all bot accounts (and, by cascade, their characters and owned data). /// @@ -229,6 +266,8 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam * characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue); DistributeStatPoints(character); + this.LearnClassSkills(context, character, characterClass, level); + character.Inventory = context.CreateNew(); character.Inventory.Money = StartMoney; this.EquipStarterGear(context, character); @@ -263,6 +302,46 @@ private static void DistributeStatPoints(Character character) character.LevelUpPoints = 0; } + /// + /// Teaches the character the class attack skills appropriate to its level, so bots fight with spells and + /// skills instead of only their weapon. Only skills the class is qualified for are ever learned (so a bot + /// can never end up with another class's magic), gated by a per-skill learn level derived from the skill's + /// attack damage (its tier). Combined with the runtime auto-selection in , + /// the character casts the strongest of these it can currently afford. + /// + private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level) + { + var learnedNumbers = new HashSet(character.LearnedSkills.Select(s => s.Skill!.Number)); + foreach (var skill in this._gameContext.Configuration.Skills) + { + if (skill.AttackDamage <= 0 + || skill.SkillType is not (SkillType.DirectHit + or SkillType.AreaSkillAutomaticHits + or SkillType.AreaSkillExplicitHits + or SkillType.AreaSkillExplicitTarget)) + { + continue; + } + + if (!skill.QualifiedCharacters.Contains(characterClass) || !learnedNumbers.Add(skill.Number)) + { + continue; + } + + var learnLevel = Math.Max(1, (int)Math.Ceiling(skill.AttackDamage * SkillLearnDamageFactor)); + if (level < learnLevel) + { + learnedNumbers.Remove(skill.Number); + continue; + } + + var entry = context.CreateNew(); + entry.Skill = skill; + entry.Level = 0; + character.LearnedSkills.Add(entry); + } + } + private static StatAttribute? GetMainDamageStat(Character character) { return character.Attributes diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index 5ed2836db..b76d6df40 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -128,7 +128,9 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings public bool PickAllItems => false; /// - public bool PickSelectItems => false; + // Must be true: the pickup handler bails out early unless PickAllItems or PickSelectItems is set, + // so with this off the selective PickZen/PickJewel/PickAncient flags below never take effect. + public bool PickSelectItems => true; /// public bool PickJewel => true; @@ -166,4 +168,13 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings /// public bool FallbackBasicAttack => true; + + /// + /// + /// Enabled for bots: they have no client-side skill configuration, so the combat AI auto-selects the + /// strongest learned attack skill the character can currently afford. Combined with the level-gated + /// skills granted at generation (see ), this makes bots cast class- and + /// level-appropriate magic/skills instead of only swinging their weapon. + /// + public bool AutoSelectBestSkill => true; } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 910cd7d1a..b158b55d9 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using System.Collections.Concurrent; using System.Threading; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; @@ -92,14 +93,18 @@ internal sealed class BotNavigator : AsyncDisposable private static readonly TimeSpan StuckTimeout = TimeSpan.FromSeconds(8); /// - /// A shared full-map path finder for long-distance travel. The pooled path finder uses a small scoped - /// grid (it rejects start/end further apart than ~16 tiles), so it can only do local movement; this one - /// uses a which resolves routes across the whole map. A single instance is - /// reused under because a path finder is not safe for concurrent use. + /// A pool of full-map path finders for long-distance travel. The pooled (scoped) path finder rejects + /// start/end further apart than ~16 tiles, so travel uses a which resolves + /// routes across the whole map. A path finder is not safe for concurrent use, so we keep a small pool and + /// hand one out per call: several bots can plan routes in parallel (a single shared instance serialized + /// every bot and starved navigation once the population reached the hundreds), while the pool size caps + /// how many CPU cores whole-map searches may occupy at once. /// - private static readonly PathFinder TravelPathFinder = CreateTravelPathFinder(); + private static readonly int TravelPathFinderPoolSize = Math.Clamp(Environment.ProcessorCount / 3, 2, 6); - private static readonly SemaphoreSlim TravelPathFinderLock = new(1, 1); + private static readonly SemaphoreSlim TravelPathFinderPool = new(TravelPathFinderPoolSize, TravelPathFinderPoolSize); + + private static readonly ConcurrentBag TravelPathFinders = CreateTravelPathFinderPool(); private readonly OfflinePlayer _player; private readonly CancellationTokenSource _cts = new(); @@ -310,15 +315,26 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) var position = this._player.Position; IList? path; - await TravelPathFinderLock.WaitAsync().ConfigureAwait(false); + await TravelPathFinderPool.WaitAsync().ConfigureAwait(false); + PathFinder? finder = null; try { - TravelPathFinder.ResetPathFinder(); - path = TravelPathFinder.FindPath(position, destination, map.Terrain.AIgrid, true); + if (!TravelPathFinders.TryTake(out finder)) + { + finder = CreateTravelPathFinder(); + } + + finder.ResetPathFinder(); + path = finder.FindPath(position, destination, map.Terrain.AIgrid, true); } finally { - TravelPathFinderLock.Release(); + if (finder is not null) + { + TravelPathFinders.Add(finder); + } + + TravelPathFinderPool.Release(); } if (path is null || path.Count == 0) @@ -420,6 +436,17 @@ private static PathFinder CreateTravelPathFinder() }; } + private static ConcurrentBag CreateTravelPathFinderPool() + { + var pool = new ConcurrentBag(); + for (var i = 0; i < TravelPathFinderPoolSize; i++) + { + pool.Add(CreateTravelPathFinder()); + } + + return pool; + } + private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); /// The highest monster level a bot of the given level should fight (see ). diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs new file mode 100644 index 000000000..14866209d --- /dev/null +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -0,0 +1,74 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Teaches a server-side bot the class attack skills it becomes eligible for as it levels up during play, +/// so its spell repertoire grows over its lifetime just like a real player's. Skills are only ever learned +/// for the character's own class (), gated by a per-skill learn level +/// derived from the skill's attack damage - the same rule the applies at creation. +/// +[PlugIn] +[Display(Name = "Bot skill progression", Description = "Teaches server-side bots new class- and level-appropriate attack skills as they level up.")] +[Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")] +public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn +{ + /// + /// Skill-tier scaling: kept in sync with so runtime learning follows the same + /// progression a freshly generated bot of the new level would already have. + /// + private const double SkillLearnDamageFactor = 0.9; + + /// + public void CharacterLeveledUp(Player player) + { + if (player.Account?.IsBot != true + || player.SelectedCharacter?.CharacterClass is not { } characterClass + || player.SkillList is not { } skillList) + { + return; + } + + try + { + var level = player.Level; + foreach (var skill in player.GameContext.Configuration.Skills) + { + if (skill.AttackDamage <= 0 + || skill.SkillType is not (SkillType.DirectHit + or SkillType.AreaSkillAutomaticHits + or SkillType.AreaSkillExplicitHits + or SkillType.AreaSkillExplicitTarget) + || !skill.QualifiedCharacters.Contains(characterClass) + || skillList.ContainsSkill((ushort)skill.Number)) + { + continue; + } + + var learnLevel = Math.Max(1, (int)Math.Ceiling(skill.AttackDamage * SkillLearnDamageFactor)); + if (level < learnLevel) + { + continue; + } + + // For an offline bot the view invocation is a no-op and the in-memory skill maps are updated + // synchronously, so the skill is immediately available to the combat AI; the new SkillEntry is + // persisted by the periodic save (no own SaveChanges here, to avoid extra concurrency pressure). + _ = skillList.AddLearnedSkillAsync(skill); + player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, level); + } + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Failed to progress bot skills for '{Name}'.", player.Name); + } + } +} diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs index 9dad4bdc1..a62a80d89 100644 --- a/src/GameLogic/MuHelper/IMuHelperSettings.cs +++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs @@ -152,4 +152,12 @@ public interface IMuHelperSettings /// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used. bool FallbackBasicAttack { get; } + + /// + /// Gets a value indicating whether the combat AI should automatically cast the strongest learned + /// attack skill the character can currently afford, instead of relying on the explicitly configured + /// skill IDs. Used by server-side bots (which have no client-side MU Helper config) so they fight + /// with class- and level-appropriate skills; human offline sessions keep their explicit configuration. + /// + bool AutoSelectBestSkill { get; } } diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 4f580c16b..6f210344b 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -292,10 +292,12 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil 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; } @@ -318,9 +320,56 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil } } + // 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; } + /// + /// 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. + /// + private SkillEntry? SelectBestAffordableSkill() + { + 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; + } + } + + return best; + } + /// /// Evaluates whether the skill in the given slot should fire this tick. /// @@ -516,6 +565,15 @@ private byte GetEffectiveAttackRange() } } + // 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)) { diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs index e1dddb0d2..42ad655f6 100644 --- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs +++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs @@ -155,6 +155,10 @@ public sealed class MuHelperSettings : IMuHelperSettings /// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used. public bool FallbackBasicAttack { get; init; } + /// + /// Human MU Helper sessions drive their skills through the explicit client configuration, so this is always false. + public bool AutoSelectBestSkill => false; + /// public override string ToString() { From 1270e379f09f2af605a506006447c2f79116d4bc Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 06:58:52 +0200 Subject: [PATCH 10/60] bots: home in on nearest live monster so they actually hunt At 250 bots most stood around in town and never fought: only ~13% gained any experience. The navigator dropped each bot on a random tile inside a monster spawn area, but MU spawn areas span almost the whole map (Lorencia's are ~86x233 / ~105x68 tiles) with only a few dozen monsters, so a random point was almost never within the 6-tile combat range of an actual monster. The bot arrived, found nothing to fight, waited, and re-picked another random tile forever. Now, once the bot is anywhere in the region, it scans a 40-tile radius via the area-of-interest manager for the nearest live monster it can safely fight and heads straight for it; the coarse spawn-area / warp logic remains only as a fallback for when nothing is loaded nearby (still in town, or all monsters too strong). Unreachable monsters are dropped so the bot relocates instead of re-targeting them. Measured at 250 bots: actively-fighting fraction 13% -> 73%, with the rest purposefully travelling; CPU/RAM unchanged (~31%/760MB). --- src/GameLogic/Bots/BotNavigator.cs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index b158b55d9..04e402b9e 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -51,6 +51,16 @@ internal sealed class BotNavigator : AsyncDisposable /// Range (tiles) around the origin that counts as "at the hunting ground". private const int HuntingRange = 6; + /// + /// How far the bot looks for an actual live monster to home in on. MU spawn areas span most of the map + /// (e.g. Lorencia's are ~86x233 / ~105x68 tiles) with only a few dozen monsters each, so their monsters + /// sit ~20 tiles apart. Walking to a random tile inside such an area almost never lands within the 6-tile + /// combat range of a real monster - the bot arrives, finds nothing to fight, and wanders off. So once the + /// bot is anywhere in the region it scans this radius for the nearest live monster and heads straight for + /// it. Kept modest so the per-tick area-of-interest scan stays cheap even with hundreds of bots. + /// + private const int MonsterSeekRadius = 40; + /// Item group of healing potions (Apple / Small / Medium / Large all share group 14). private const int HealPotionGroup = 14; @@ -251,6 +261,27 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // Home in on the nearest live monster within the seek radius instead of walking to a random tile in a + // map-sized spawn area (where the bot would almost never arrive within combat range of an actual + // monster). This is what makes the bot converge on real monsters and actually fight. The bot walks + // toward the monster; once within the combat range the branch above takes over and the combat handler + // engages. Only when nothing is loaded within the seek radius (e.g. still in town, or all nearby + // monsters are too strong) do we fall back to the coarse spawn-area / warp logic below to relocate. + if (!inSafezone && this.TryFindNearestMonsterGround(map, out var monsterGround)) + { + this._destination = monsterGround; + this._hasDestination = true; + if (await this.TravelTowardAsync(map, monsterGround).ConfigureAwait(false)) + { + this._emptyGroundSince = null; + return; + } + + // Unreachable monster (e.g. across a river): drop it and fall through to pick a spawn-area ground + // or warp, instead of re-targeting the same unreachable monster every tick. + this._hasDestination = false; + } + // Nothing to fight here. If we still have a destination to reach, keep walking towards it. If the // route turned out to be impossible (e.g. across a river), drop it and fall through to pick another // ground in this same tick instead of standing still. @@ -447,6 +478,55 @@ private static ConcurrentBag CreateTravelPathFinderPool() return pool; } + /// + /// Finds the position of the nearest live, attackable monster within that + /// the bot can safely fight, so the bot heads straight for a real monster instead of a random tile in a + /// huge spawn area. Returns false when none are loaded nearby (or all are above the safe level cap), in + /// which case the caller falls back to coarse spawn-area travel / warping to relocate. + /// + private bool TryFindNearestMonsterGround(GameMap map, out Point ground) + { + ground = default; + var position = this._player.Position; + var cap = GetHuntCap(this.GetBotLevel()); + Monster? nearest = null; + var nearestDistance = double.MaxValue; + foreach (var attackable in map.GetAttackablesInRange(position, MonsterSeekRadius)) + { + if (attackable is not Monster monster + || !monster.IsAlive + || monster.IsAtSafezone()) + { + continue; + } + + var level = GetMonsterLevel(monster.Definition); + if (level <= 0 || level > cap) + { + // Above the safe cap (too tough) - leave it and let the warp / area logic relocate the bot + // to a map whose monsters it can handle. + continue; + } + + var distance = monster.GetDistanceTo(position); + if (distance < nearestDistance) + { + nearest = monster; + nearestDistance = distance; + } + } + + if (nearest is null) + { + return false; + } + + // The monster stands on a walkable tile, so head straight for it; TravelTowardAsync walks a few steps + // per tick and re-homes as the monster roams, closing the gap until combat range is reached. + ground = nearest.Position; + return true; + } + private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); /// The highest monster level a bot of the given level should fight (see ). From 5ca0430ea92c6363423f0998ffa5125db4b403f6 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 08:27:48 +0200 Subject: [PATCH 11/60] bots: class buffs and heals, equipment progression, smarter and safer hunting Buffs/heals: bots now learn and use their class's support skills like a real player - elf Heal/Greater Defense/Greater Damage, Rage Fighter Increase Block/Increase Health, and (once high enough) DK Swell Life etc. Skill learning (generation and level-up) is now gated by the skills' real learn requirements from the game configuration (total energy, leadership, character level) instead of a damage heuristic, so a bot knows exactly what a human of the same build could know. Stat points are invested per a class build (elves and rage fighters keep enough energy for their support skills, dark lords raise leadership), and points earned at runtime are now spent on level-up too - previously they accumulated unused. The offline buff handler can auto-rotate the learned buffs (AutoSelectBuffs), the heal handler casts the class heal before drinking potions, and enemy debuffs (summoner Sleep/Weakness/Innovation) and the shield-granted Defense skill are excluded so a bot never puts itself to sleep. Casters also carry and drink mana potions now - before, they degraded to weak melee once their mana ran dry. Equipment progression: dropped gear is evaluated before pickup and only class-appropriate upgrades are collected; a new BotEquipmentHandler periodically equips the best of them through the regular MoveItemAction (which enforces the usual requirements) and drops the replaced piece, so backpacks don't silt up. Weapons must match the class's fighting style (an elf only considers bows, a wizard staves) and ammunition is never displaced. Bots also get the four inventory extensions for buffer space. Hunting intelligence (from a review of the bot AI): the combat AI now only engages monsters up to the navigator's safe level cap (bots no longer pick suicidal fights while travelling - deaths dropped to zero in validation); unreachable targets (across a wall/river) are briefly blacklisted instead of freezing the bot; monster homing also works inside the safezone, so bots leave town straight toward the nearest monsters; travel routes are cached and consumed hop by hop instead of re-planning a whole-map A* every second; target selection is randomized among the nearest candidates to avoid dogpiling; and navigator start times are jittered. Validated live with 250 bots: 100% of them gained experience in a 6-minute window (73% before this change, 13% before the homing fix), zero deaths, zero errors, weapon archetypes clean across all classes, mana potions consumed by all skill-using classes. --- src/GameLogic/Bots/BotEquipmentHandler.cs | 223 ++++++++++++++++++ src/GameLogic/Bots/BotGenerator.cs | 106 ++++----- src/GameLogic/Bots/BotMuHelperSettings.cs | 25 +- src/GameLogic/Bots/BotNavigator.cs | 176 ++++++++++---- src/GameLogic/Bots/BotProgression.cs | 208 ++++++++++++++++ .../Bots/BotSkillProgressionPlugIn.cs | 99 +++++--- src/GameLogic/MuHelper/IMuHelperSettings.cs | 29 +++ src/GameLogic/Offline/BuffHandler.cs | 22 +- src/GameLogic/Offline/CombatHandler.cs | 88 ++++++- src/GameLogic/Offline/HealingHandler.cs | 34 ++- src/GameLogic/Offline/ItemPickupHandler.cs | 7 + src/GameLogic/Offline/MovementHandler.cs | 7 +- .../RemoteView/MuHelper/MuHelperSettings.cs | 16 ++ 13 files changed, 884 insertions(+), 156 deletions(-) create mode 100644 src/GameLogic/Bots/BotEquipmentHandler.cs create mode 100644 src/GameLogic/Bots/BotProgression.cs diff --git a/src/GameLogic/Bots/BotEquipmentHandler.cs b/src/GameLogic/Bots/BotEquipmentHandler.cs new file mode 100644 index 000000000..be3d0090a --- /dev/null +++ b/src/GameLogic/Bots/BotEquipmentHandler.cs @@ -0,0 +1,223 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; + +/// +/// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup +/// (see , used by the offline ), and looted +/// upgrades are periodically equipped, with the replaced piece dropped to the ground so the backpack +/// does not silt up with junk. All moves go through the regular , which +/// enforces the same class- and stat-requirements as for a human player. +/// +internal static class BotEquipmentHandler +{ + /// A candidate must beat the equipped piece by at least this score margin to be worth swapping. + private const int UpgradeScoreMargin = 1; + + private static readonly MoveItemAction MoveAction = new(); + private static readonly DropItemAction DropAction = new(); + + /// + /// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear, + /// so the pickup handler only collects items worth carrying. + /// + public static bool IsUpgradeFor(Player player, Item item) + { + if (item.Definition is not { } definition + || player.SelectedCharacter?.CharacterClass is not { } characterClass + || player.Inventory is not { } inventory) + { + return false; + } + + if (!IsWearableCandidate(definition, characterClass) + || !player.CompliesRequirements(item)) + { + return false; + } + + var candidateScore = Score(item); + foreach (var slot in definition.ItemSlot!.ItemSlots) + { + var equipped = inventory.GetItem((byte)slot); + if (equipped?.Definition?.IsAmmunition == true) + { + // Never displace the ammunition (an archer's arrows) - the bow would stop working. + continue; + } + + if (equipped is null || Score(equipped) + UpgradeScoreMargin <= candidateScore) + { + return true; + } + } + + return false; + } + + /// + /// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which - + /// if it is a weapon - matches the class's fighting style (an elf only considers bows, a wizard only + /// staves), so bots don't fill their off-hand with random qualified junk like a Small Axe. + /// + private static bool IsWearableCandidate(ItemDefinition definition, CharacterClass characterClass) + { + const byte lastWeaponGroup = 5; + return definition.ItemSlot is { ItemSlots.Count: > 0 } + && !definition.IsAmmunition + && definition.QualifiedCharacters.Contains(characterClass) + && (definition.Group > lastWeaponGroup || BotProgression.IsPreferredWeaponGroup(characterClass, (byte)definition.Group)); + } + + /// + /// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece is + /// dropped to the ground (a real player would leave the outgrown junk behind too), unless it is + /// something valuable (excellent/ancient), which stays in the backpack. + /// + public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player) + { + if (player.Inventory is not { } inventory + || player.SelectedCharacter?.CharacterClass is not { } characterClass) + { + return; + } + + // Snapshot, because equipping mutates the item collection while we iterate. + var backpackItems = inventory.Items + .Where(i => i.ItemSlot >= InventoryConstants.EquippableSlotsCount) + .ToList(); + + foreach (var item in backpackItems) + { + if (item.Definition is not { } definition + || !IsWearableCandidate(definition, characterClass) + || !player.CompliesRequirements(item)) + { + continue; + } + + var candidateScore = Score(item); + + // Prefer an empty qualified slot; otherwise replace the weakest equipped piece it beats. + byte? targetSlot = null; + Item? replaced = null; + foreach (var slot in definition.ItemSlot!.ItemSlots) + { + var equipped = inventory.GetItem((byte)slot); + if (equipped?.Definition?.IsAmmunition == true) + { + // Never displace the ammunition (an archer's arrows) - the bow would stop working. + continue; + } + + if (equipped is null) + { + targetSlot = (byte)slot; + replaced = null; + break; + } + + if (Score(equipped) + UpgradeScoreMargin <= candidateScore + && (replaced is null || Score(equipped) < Score(replaced))) + { + targetSlot = (byte)slot; + replaced = equipped; + } + } + + if (targetSlot is not { } equipSlot) + { + continue; + } + + if (replaced is not null) + { + // Make room: move the old piece to a free backpack slot first. + if (FindFreeBackpackSlot(inventory) is not { } freeSlot) + { + continue; + } + + await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(equipSlot) is not null) + { + // The unequip was rejected - don't try to force the swap. + continue; + } + } + + var fromSlot = item.ItemSlot; + await MoveAction.MoveItemAsync(player, fromSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(equipSlot) != item) + { + // Equip rejected (e.g. requirements after all) - leave everything as is. + continue; + } + + player.Logger.LogInformation( + "Bot '{Name}' equipped '{New}'{Replaced}.", + player.Name, + item, + replaced is null ? string.Empty : $" (replacing '{replaced}')"); + + if (replaced is not null && !IsWorthKeeping(replaced)) + { + try + { + await DropAction.DropItemAsync(player, replaced.ItemSlot, player.Position).ConfigureAwait(false); + } + catch (InvalidOperationException ex) + { + // E.g. the map ran out of object ids for another drop - not worth failing the swap + // over; the outgrown piece simply stays in the backpack (plenty of room with the + // extended inventory) until a later pass or forever. + player.Logger.LogDebug(ex, "Bot '{Name}' could not drop replaced item '{Item}'.", player.Name, replaced); + } + } + + // One swap per pass keeps the work per tick small; the next pass picks up the rest. + return; + } + } + + /// + /// A rough, monotonic quality score of an equippable item: the definition's drop level tracks the + /// gear tier, the item level its upgrades, and excellent/ancient options add their extra worth. + /// + private static int Score(Item item) + { + if (item.Definition is not { } definition) + { + return 0; + } + + var score = definition.DropLevel + (item.Level * 3); + score += 12 * item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent); + if (item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0)) + { + score += 15; + } + + return score; + } + + private static bool IsWorthKeeping(Item item) + { + return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent) + || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0); + } + + private static byte? FindFreeBackpackSlot(IStorage inventory) + { + return inventory.FreeSlots + .Where(s => s >= InventoryConstants.EquippableSlotsCount) + .Cast() + .FirstOrDefault(); + } +} diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index c91236b34..3ddfb9a63 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -34,13 +34,8 @@ internal sealed class BotGenerator /// Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp. private const byte StarterItemLevel = 6; - /// - /// Skill-tier scaling: a class attack skill becomes learnable once the character level reaches roughly - /// its attack-damage rating times this factor. Attack damage is a monotonic proxy for the skill tier - /// within a class (e.g. Dark Wizard: Energy Ball 3 → Hellfire 120), so higher-level bots progressively - /// unlock stronger spells while low-level ones only get the basics - always only for their own class. - /// - private const double SkillLearnDamageFactor = 0.9; + /// Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack. + private const int BotInventoryExtensions = 4; /// Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear). private const byte MaxMeleeGroup = 3; @@ -264,7 +259,8 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)]; character.LevelUpPoints = (int)((level - 1) * characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue); - DistributeStatPoints(character); + character.InventoryExtensions = BotInventoryExtensions; + DistributeStatPoints(character, characterClass); this.LearnClassSkills(context, character, characterClass, level); @@ -278,10 +274,11 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam /// /// Spends the character's level-up points, so a high-level bot actually has high-level stats. /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and - /// damage) and die instantly. Half of the points go into vitality (health/survival) and half into - /// the class's main damage stat. + /// damage) and die instantly. The split follows the class build in : + /// vitality and the damage stat for everyone, plus the energy/leadership the class's own support + /// skills require - the same split the bot keeps using for points it earns at runtime. /// - private static void DistributeStatPoints(Character character) + private static void DistributeStatPoints(Character character, CharacterClass characterClass) { var points = character.LevelUpPoints; if (points <= 0) @@ -289,49 +286,47 @@ private static void DistributeStatPoints(Character character) return; } - var mainStat = GetMainDamageStat(character); - var vitality = character.Attributes.FirstOrDefault(a => a.Definition == Stats.BaseVitality); - if (mainStat is null || vitality is null) + var weights = BotProgression.GetStatWeights(characterClass); + foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) { - return; + var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat); + if (attribute is not null) + { + attribute.Value += amount; + character.LevelUpPoints -= amount; + } } - - var toVitality = points / 2; - vitality.Value += toVitality; - mainStat.Value += points - toVitality; - character.LevelUpPoints = 0; } /// - /// Teaches the character the class attack skills appropriate to its level, so bots fight with spells and - /// skills instead of only their weapon. Only skills the class is qualified for are ever learned (so a bot - /// can never end up with another class's magic), gated by a per-skill learn level derived from the skill's - /// attack damage (its tier). Combined with the runtime auto-selection in , - /// the character casts the strongest of these it can currently afford. + /// Teaches the character the class skills appropriate to its level and stats - attack skills as well + /// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the + /// class is qualified for are ever learned, gated by the skills' real learn requirements from the game + /// configuration (total energy, leadership, character level, ...) evaluated against the stats the bot + /// was just given - exactly the requirements a human player has to meet for the same skill. /// private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level) { - var learnedNumbers = new HashSet(character.LearnedSkills.Select(s => s.Skill!.Number)); - foreach (var skill in this._gameContext.Configuration.Skills) + float? GetValue(AttributeDefinition attribute) { - if (skill.AttackDamage <= 0 - || skill.SkillType is not (SkillType.DirectHit - or SkillType.AreaSkillAutomaticHits - or SkillType.AreaSkillExplicitHits - or SkillType.AreaSkillExplicitTarget)) + if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat) { - continue; + return null; } - if (!skill.QualifiedCharacters.Contains(characterClass) || !learnedNumbers.Add(skill.Number)) - { - continue; - } + return baseStat == Stats.Level + ? level + : character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value; + } - var learnLevel = Math.Max(1, (int)Math.Ceiling(skill.AttackDamage * SkillLearnDamageFactor)); - if (level < learnLevel) + var learnedNumbers = new HashSet(character.LearnedSkills.Select(s => s.Skill!.Number)); + foreach (var skill in this._gameContext.Configuration.Skills) + { + if (!BotProgression.IsBotLearnableSkill(skill) + || !skill.QualifiedCharacters.Contains(characterClass) + || !BotProgression.MeetsRequirements(skill, GetValue) + || !learnedNumbers.Add(skill.Number)) { - learnedNumbers.Remove(skill.Number); continue; } @@ -342,17 +337,6 @@ or SkillType.AreaSkillExplicitHits } } - private static StatAttribute? GetMainDamageStat(Character character) - { - return character.Attributes - .Where(a => a.Definition == Stats.BaseStrength - || a.Definition == Stats.BaseAgility - || a.Definition == Stats.BaseEnergy - || a.Definition == Stats.BaseLeadership) - .OrderByDescending(a => a.Value) - .FirstOrDefault(); - } - /// /// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test /// account gear), so it is not naked and punching with its fists. The item level scales modestly @@ -429,14 +413,22 @@ float ClassStat(AttributeDefinition attribute) break; } - this.AddHealthPotions(context, inventory); + this.AddPotions(context, inventory); } - private void AddHealthPotions(IPlayerContext context, ItemStorage inventory) + private void AddPotions(IPlayerContext context, ItemStorage inventory) { - // A stack of Large Healing Potions so the offline HealingHandler has something to drink. The - // BotNavigator tops this up at runtime, so the bot never runs dry. Durability holds the stack count. - var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == 3); + // A stack of Large Healing Potions so the offline HealingHandler has something to drink, and a + // stack of Large Mana Potions so casters can keep casting instead of degrading to weak melee once + // their mana runs dry. The BotNavigator tops both up at runtime, so the bot never runs out. + // Durability holds the stack count. + this.AddPotionStack(context, inventory, 3, InventoryConstants.EquippableSlotsCount); // Large Healing Potion, first backpack slot + this.AddPotionStack(context, inventory, 6, (byte)(InventoryConstants.EquippableSlotsCount + 1)); // Large Mana Potion, second backpack slot + } + + private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte potionNumber, byte slot) + { + var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == potionNumber); if (potion is null) { return; @@ -445,7 +437,7 @@ private void AddHealthPotions(IPlayerContext context, ItemStorage inventory) var item = context.CreateNew(); item.Definition = potion; item.Durability = 255; - item.ItemSlot = InventoryConstants.EquippableSlotsCount; // first backpack slot + item.ItemSlot = slot; inventory.Items.Add(item); } diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index b76d6df40..61f43bf47 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -91,11 +91,13 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings /// public int BuffCastIntervalSeconds => 0; + // With the class heal skill learned (e.g. elf Heal), the HealingHandler casts it below the threshold + // before falling back to potions - the same order a real player follows. /// - public bool AutoHeal => false; + public bool AutoHeal => true; /// - public int HealThresholdPercent => 30; + public int HealThresholdPercent => 60; /// public bool UseDrainLife => false; @@ -177,4 +179,23 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings /// level-appropriate magic/skills instead of only swinging their weapon. /// public bool AutoSelectBestSkill => true; + + /// + /// Bots keep their class's learned buffs up automatically (e.g. elf Greater Defense/Greater Damage). + public bool AutoSelectBuffs => true; + + /// + /// Casters drink mana potions, so they keep casting instead of degrading to weak melee. + public bool UseManaPotion => true; + + /// + /// + /// Bots only engage monsters they can handle (the navigator's safe-monster cap). Without this, a bot + /// travelling through hostile territory picks fights with monsters far above its level and dies. + /// + public bool OnlyHuntSafeMonsters => true; + + /// + /// Bots evaluate dropped gear and pick up upgrades for their own class (see ). + public bool PickUpgradeItems => true; } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 04e402b9e..14fcc9021 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -21,21 +21,9 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// internal sealed class BotNavigator : AsyncDisposable { - private static readonly TimeSpan StartDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); - /// - /// The strongest monster a bot should fight, as a fraction of its own level. In MU a monster is far - /// tougher than a character of the same level (a well-geared level 400 only just manages the ~140 - /// level monsters of the hardest map), so bots target monsters well below their own level to survive - /// while still earning experience. Tunable; lower = safer/slower, higher = more experience/deadlier. - /// - private const float SafeMonsterFactor = 0.5f; - - /// The minimum monster level a bot will hunt, so very low-level bots still find targets. - private const int MinHuntCap = 3; - /// A bot only warps to another map if it offers monsters at least this many levels stronger (avoids hopping for tiny gains). private const int WarpImprovementMargin = 3; @@ -61,12 +49,15 @@ internal sealed class BotNavigator : AsyncDisposable /// private const int MonsterSeekRadius = 40; - /// Item group of healing potions (Apple / Small / Medium / Large all share group 14). + /// Item group of potions (healing and mana potions share group 14). private const int HealPotionGroup = 14; /// Item number of the Large Healing Potion within . private const int HealPotionNumber = 3; + /// Item number of the Large Mana Potion within . + private const int ManaPotionNumber = 6; + /// Stack size (the potion item's Durability holds the remaining count; one is spent per heal). private const byte HealPotionStack = 255; @@ -76,6 +67,16 @@ internal sealed class BotNavigator : AsyncDisposable /// Number of path steps to issue per travel hop. Short hops let the bot stop and fight between hops. private const int StepsPerHop = 3; + /// + /// How far the travel destination may drift (e.g. a roaming monster the bot homes in on) before the + /// cached route is re-planned. Re-planning a whole-map A* every tick just to consume three steps + /// starved the path finder pool once dozens of bots travelled at the same time. + /// + private const int PathReuseTolerance = 4; + + /// How often the bot checks its backpack for equippable upgrades. + private static readonly TimeSpan EquipCheckInterval = TimeSpan.FromSeconds(8); + /// /// If a monster is within this range the bot stops travelling and lets the combat handler engage. /// MUST equal the combat hunting range: the combat handler only ever targets monsters within @@ -102,6 +103,13 @@ internal sealed class BotNavigator : AsyncDisposable /// private static readonly TimeSpan StuckTimeout = TimeSpan.FromSeconds(8); + /// + /// Stuck timeout when huntable monsters are nearby: much longer, because a bot standing still while + /// monsters are around is usually just fighting them from one tile. It still eventually fires, which + /// breaks the rare deadlock of a monster that is in range but cannot be attacked or reached. + /// + private static readonly TimeSpan StuckWithMonstersTimeout = TimeSpan.FromSeconds(30); + /// /// A pool of full-map path finders for long-distance travel. The pooled (scoped) path finder rejects /// start/end further apart than ~16 tiles, so travel uses a which resolves @@ -127,6 +135,10 @@ internal sealed class BotNavigator : AsyncDisposable private DateTime _lastWarpUtc = DateTime.MinValue; private Point _lastPosition; private DateTime _lastMoveUtc = DateTime.MinValue; + private DateTime _nextEquipCheckUtc = DateTime.MinValue; + private IList? _travelPath; + private int _travelPathIndex; + private Point _travelPathTarget; /// /// Initializes a new instance of the class. @@ -138,14 +150,15 @@ public BotNavigator(OfflinePlayer player) } /// - /// Starts the periodic navigation evaluation. + /// Starts the periodic navigation evaluation. The start delay is jittered per bot, so hundreds of + /// navigators do not all fire on the same second boundary (smoother load, less robotic synchrony). /// public void Start() { this._timer ??= new Timer( _ => _ = this.SafeEvaluateAsync(this._cts.Token), null, - StartDelay, + TimeSpan.FromMilliseconds(2000 + Rand.NextInt(0, 1500)), EvaluationInterval); } @@ -206,10 +219,18 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } - // Make sure the bot always carries healing potions. Without them the offline HealingHandler has - // nothing to drink and the bot dies to any sustained damage. This also tops up already-generated - // bots at runtime, so the fix applies without regenerating them. - await this.EnsureHealthPotionsAsync().ConfigureAwait(false); + // Make sure the bot always carries healing and mana potions. Without them the offline handlers + // have nothing to drink: the bot dies to sustained damage, and casters degrade to weak melee + // once their mana runs dry. This also tops up already-generated bots at runtime. + await this.EnsurePotionsAsync().ConfigureAwait(false); + + // Periodically evaluate looted gear and equip upgrades (drops the replaced piece), so the bot's + // equipment progresses over its lifetime like a real player's. + if (DateTime.UtcNow >= this._nextEquipCheckUtc) + { + this._nextEquipCheckUtc = DateTime.UtcNow + EquipCheckInterval; + await BotEquipmentHandler.TryEquipUpgradesAsync(this._player).ConfigureAwait(false); + } // Keep the combat centre on the bot's current position, so the combat handler always engages // monsters right next to the bot - both at the hunting ground and while travelling through hostile @@ -230,13 +251,20 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._lastMoveUtc = DateTime.UtcNow; } + // Only monsters the bot would actually fight count (the combat AI ignores ones above its safe + // level cap), so a too-strong monster next to the bot neither stops its travel nor suppresses + // the stuck watchdog. + var huntCap = GetHuntCap(this.GetBotLevel()); var monstersNearby = map.GetAttackablesInRange(this._player.Position, TravelStopRange) .OfType() - .Count(m => m.IsAlive && !m.IsAtSafezone()); - - // The bot counts as stuck only when it has been frozen on the spot with NOTHING to fight - i.e. a - // wedged walk or a blocked path. A bot standing still because it is fighting nearby monsters is fine. - var stuck = monstersNearby == 0 && DateTime.UtcNow - this._lastMoveUtc > StuckTimeout; + .Count(m => m.IsAlive && !m.IsAtSafezone() && m.Attributes[Stats.Level] <= huntCap); + + // The bot counts as stuck when it has been frozen on the spot: quickly when there is nothing to + // fight (a wedged walk or blocked path), and after a much longer grace when huntable monsters are + // around - standing still next to monsters usually just means fighting, but if the position stays + // frozen well beyond that, something is wedged (e.g. a monster in range that cannot be reached). + var stuckTimeout = monstersNearby > 0 ? StuckWithMonstersTimeout : StuckTimeout; + var stuck = DateTime.UtcNow - this._lastMoveUtc > stuckTimeout; if (stuck) { // Break free: pick a fresh hunting ground and walk to it now. Issuing the walk resets the wedged @@ -261,13 +289,15 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } - // Home in on the nearest live monster within the seek radius instead of walking to a random tile in a - // map-sized spawn area (where the bot would almost never arrive within combat range of an actual - // monster). This is what makes the bot converge on real monsters and actually fight. The bot walks - // toward the monster; once within the combat range the branch above takes over and the combat handler - // engages. Only when nothing is loaded within the seek radius (e.g. still in town, or all nearby - // monsters are too strong) do we fall back to the coarse spawn-area / warp logic below to relocate. - if (!inSafezone && this.TryFindNearestMonsterGround(map, out var monsterGround)) + // Home in on a nearby live monster instead of walking to a random tile in a map-sized spawn area + // (where the bot would almost never arrive within combat range of an actual monster). This is what + // makes the bot converge on real monsters and actually fight. The bot walks toward the monster; + // once within the combat range the branch above takes over and the combat handler engages. This + // also runs inside the safezone, so a bot in town heads straight for the monsters just outside the + // gate instead of a random far-away spawn point. Only when nothing is loaded within the seek radius + // (deep in town, or all nearby monsters too strong) does it fall back to the coarse spawn-area / + // warp logic below to relocate. + if (this.TryFindNearestMonsterGround(map, out var monsterGround)) { this._destination = monsterGround; this._hasDestination = true; @@ -322,6 +352,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) { this._lastWarpUtc = DateTime.UtcNow; this._hasDestination = false; + this._travelPath = null; this._player.Logger.LogInformation( "Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).", this._player.Name, @@ -345,6 +376,20 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) { var position = this._player.Position; + // Follow the cached route as long as it still leads to (roughly) the same destination and the + // bot is still on it. Re-planning a whole-map A* every tick just to consume three steps wasted + // CPU and starved the shared path finder pool once dozens of bots travelled at the same time. + if (this._travelPath is { } cached + && this._travelPathIndex < cached.Count + && destination.EuclideanDistanceTo(this._travelPathTarget) <= PathReuseTolerance + && position.EuclideanDistanceTo(cached[this._travelPathIndex].Point) <= 1.5) + { + await this.WalkCachedStepsAsync(position).ConfigureAwait(false); + return true; + } + + this._travelPath = null; + IList? path; await TravelPathFinderPool.WaitAsync().ConfigureAwait(false); PathFinder? finder = null; @@ -375,17 +420,31 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) return false; } - var stepsCount = Math.Min(path.Count, StepsPerHop); + this._travelPath = path; + this._travelPathIndex = 0; + this._travelPathTarget = destination; + await this.WalkCachedStepsAsync(position).ConfigureAwait(false); + return true; + } + + /// + /// Issues the next few steps of the cached route (short hops, so the bot can stop and fight between + /// them) and advances the route cursor. + /// + private async ValueTask WalkCachedStepsAsync(Point position) + { + var path = this._travelPath!; + var stepsCount = Math.Min(path.Count - this._travelPathIndex, StepsPerHop); var steps = new WalkingStep[stepsCount]; for (var i = 0; i < stepsCount; i++) { - var node = path[i]; + var node = path[this._travelPathIndex + i]; var previous = i == 0 ? position : steps[i - 1].To; steps[i] = new WalkingStep(previous, node.Point, previous.GetDirectionTo(node.Point)); } + this._travelPathIndex += stepsCount; await this._player.WalkToAsync(steps[stepsCount - 1].To, steps).ConfigureAwait(false); - return true; } /// @@ -417,11 +476,17 @@ private async ValueTask PickGroundAndTravelAsync(GameMap map) } /// - /// Ensures the bot keeps a stack of healing potions in its inventory. Creates the stack the first time - /// (covering bots generated before potions were granted) and refills it once it runs low, so the bot - /// never goes without a way to heal. + /// Ensures the bot keeps a stack of healing and a stack of mana potions in its inventory. Creates the + /// stacks the first time (covering bots generated before they were granted) and refills them once they + /// run low, so the bot never goes without a way to heal or to keep casting. /// - private async ValueTask EnsureHealthPotionsAsync() + private async ValueTask EnsurePotionsAsync() + { + await this.EnsurePotionStackAsync(HealPotionNumber).ConfigureAwait(false); + await this.EnsurePotionStackAsync(ManaPotionNumber).ConfigureAwait(false); + } + + private async ValueTask EnsurePotionStackAsync(int potionNumber) { if (this._player.Inventory is not { } inventory) { @@ -429,7 +494,7 @@ private async ValueTask EnsureHealthPotionsAsync() } var potion = inventory.Items.FirstOrDefault(i => - i.Definition?.Group == HealPotionGroup && i.Definition.Number == HealPotionNumber); + i.Definition?.Group == HealPotionGroup && i.Definition.Number == potionNumber); if (potion is not null) { if (potion.Durability < HealPotionRefillBelow) @@ -441,7 +506,7 @@ private async ValueTask EnsureHealthPotionsAsync() } var definition = this._player.GameContext.Configuration.Items - .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == HealPotionNumber); + .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == potionNumber); if (definition is null) { return; @@ -489,8 +554,7 @@ private bool TryFindNearestMonsterGround(GameMap map, out Point ground) ground = default; var position = this._player.Position; var cap = GetHuntCap(this.GetBotLevel()); - Monster? nearest = null; - var nearestDistance = double.MaxValue; + var candidates = new List<(Monster Monster, double Distance)>(); foreach (var attackable in map.GetAttackablesInRange(position, MonsterSeekRadius)) { if (attackable is not Monster monster @@ -508,29 +572,37 @@ private bool TryFindNearestMonsterGround(GameMap map, out Point ground) continue; } - var distance = monster.GetDistanceTo(position); - if (distance < nearestDistance) - { - nearest = monster; - nearestDistance = distance; - } + candidates.Add((monster, monster.GetDistanceTo(position))); + } + + if (candidates.Count == 0) + { + return false; } - if (nearest is null) + // Head for one of the few nearest monsters, chosen randomly, instead of strictly the nearest: + // with many bots on one ground, deterministic nearest-first would send them all to the same + // monster and they would roam the map as one pack - a very bot-like look. + var chosen = candidates + .OrderBy(c => c.Distance) + .Take(3) + .Select(c => c.Monster) + .SelectRandom(); + if (chosen is null) { return false; } // The monster stands on a walkable tile, so head straight for it; TravelTowardAsync walks a few steps // per tick and re-homes as the monster roams, closing the gap until combat range is reached. - ground = nearest.Position; + ground = chosen.Position; return true; } private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); - /// The highest monster level a bot of the given level should fight (see ). - private static int GetHuntCap(int botLevel) => Math.Max(MinHuntCap, (int)(botLevel * SafeMonsterFactor)); + /// The highest monster level a bot of the given level should fight - shared with the combat AI. + private static int GetHuntCap(int botLevel) => CombatHandler.GetSafeHuntCap(botLevel); /// The strongest monster level on the map which is still at or below the safe cap; 0 if none. private static int BestHuntableLevel(GameMapDefinition mapDefinition, int cap) diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs new file mode 100644 index 000000000..e02f1bb83 --- /dev/null +++ b/src/GameLogic/Bots/BotProgression.cs @@ -0,0 +1,208 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; + +/// +/// The shared progression rules of server-side bots: how a bot of a given class invests its stat +/// points, and which skills it may learn. Used by the when a bot is +/// created and by the when it levels up during play, so a +/// freshly generated bot and one that grew to the same level in-game end up with the same build. +/// +internal static class BotProgression +{ + /// + /// The character class numbers from the game's data model (CharacterClassNumber lives in the + /// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here). + /// + private const byte FairyElfNumber = 8; + private const byte DarkLordNumber = 16; + private const byte RageFighterNumber = 24; + + /// + /// How a bot invests its stat points, per class. Mirrors a sensible human build: everyone puts a + /// large share into vitality (survival) and their damage stat; classes whose class skills have + /// stat requirements additionally invest what those skills need - elves keep enough energy for + /// their heal/defense/damage orbs, rage fighters for their buffs, dark lords raise leadership + /// like a real player would. The requirement gate in then unlocks + /// those skills exactly when the grown stats reach the game's own thresholds. + /// + public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass) + { + return characterClass.Number switch + { + FairyElfNumber => new[] { (Stats.BaseVitality, 40), (Stats.BaseAgility, 40), (Stats.BaseEnergy, 20) }, + DarkLordNumber => new[] { (Stats.BaseStrength, 35), (Stats.BaseVitality, 30), (Stats.BaseLeadership, 25), (Stats.BaseEnergy, 10) }, + RageFighterNumber => new[] { (Stats.BaseStrength, 45), (Stats.BaseVitality, 35), (Stats.BaseEnergy, 20) }, + _ => new[] { (GetMainDamageStat(characterClass), 50), (Stats.BaseVitality, 50) }, + }; + } + + /// + /// Splits the given points proportionally to the class's stat weights, returning whole-point + /// amounts which sum up to (the remainder goes to the first stat). + /// + public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(int points, IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights) + { + var totalWeight = weights.Sum(w => w.Weight); + if (points <= 0 || totalWeight <= 0) + { + yield break; + } + + var assigned = 0; + for (var i = 1; i < weights.Count; i++) + { + var amount = points * weights[i].Weight / totalWeight; + assigned += amount; + if (amount > 0) + { + yield return (weights[i].Stat, amount); + } + } + + yield return (weights[0].Stat, points - assigned); + } + + /// + /// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's + /// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the + /// bot would put itself to sleep), and Defense (18), which players get from equipping a shield + /// rather than learning it. + /// + private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222]; + + /// + /// Determines whether the skill is one a bot may learn: an actual attack skill, or a self/party + /// buff or heal with a magic effect (which the offline buff/heal handlers know how to cast). + /// Passive boosts, event skills, enemy debuffs and utility skills are left out. + /// + public static bool IsBotLearnableSkill(Skill skill) + { + if (skill.AttackDamage > 0 + && skill.SkillType is SkillType.DirectHit + or SkillType.AreaSkillAutomaticHits + or SkillType.AreaSkillExplicitHits + or SkillType.AreaSkillExplicitTarget) + { + return true; + } + + return skill.SkillType is SkillType.Buff or SkillType.Regeneration + && skill.MagicEffectDef is not null + && !ExcludedBuffSkillNumbers.Contains(skill.Number); + } + + /// + /// Determines whether the character meets the skill's learn requirements (the same ones the game + /// enforces when casting, e.g. total energy for wizard spells or character level for knight skills). + /// resolves an attribute's current value; returning null means + /// the attribute is unknown in the caller's context, which conservatively fails the requirement. + /// + public static bool MeetsRequirements(Skill skill, Func getAttributeValue) + { + foreach (var requirement in skill.Requirements) + { + if (requirement.Attribute is not { } attribute) + { + continue; + } + + if (getAttributeValue(attribute) is not { } value || value < requirement.MinimumValue) + { + return false; + } + } + + return true; + } + + /// + /// Maps a "total" attribute (used by skill requirements) to the base stat a generated character + /// actually has, so requirements can be evaluated before the character was ever composed at runtime. + /// Returns null for attributes that have no base-stat counterpart. + /// + public static AttributeDefinition? TotalToBaseStat(AttributeDefinition attribute) + { + if (attribute == Stats.TotalEnergy) + { + return Stats.BaseEnergy; + } + + if (attribute == Stats.TotalStrength) + { + return Stats.BaseStrength; + } + + if (attribute == Stats.TotalAgility) + { + return Stats.BaseAgility; + } + + if (attribute == Stats.TotalVitality) + { + return Stats.BaseVitality; + } + + if (attribute == Stats.TotalLeadership) + { + return Stats.BaseLeadership; + } + + if (attribute == Stats.Level) + { + return Stats.Level; + } + + return null; + } + + /// + /// Determines whether a weapon of the given item group fits the class's fighting style: archers + /// (agility-based classes) use bows, pure casters staves, everyone else melee weapons. Mirrors the + /// starter-weapon choice of the , so an elf never swaps its bow for a + /// random axe it happens to be qualified for (which would also displace its arrows). + /// + public static bool IsPreferredWeaponGroup(CharacterClass characterClass, byte itemGroup) + { + const byte maxMeleeGroup = 3; + const byte bowGroup = 4; + const byte staffGroup = 5; + + float ClassStat(AttributeDefinition attribute) + => characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f; + + var strength = ClassStat(Stats.BaseStrength); + var agility = ClassStat(Stats.BaseAgility); + var energy = ClassStat(Stats.BaseEnergy); + + if (agility > strength && agility > energy) + { + return itemGroup == bowGroup; + } + + if (energy > strength) + { + return itemGroup == staffGroup; + } + + return itemGroup <= maxMeleeGroup; + } + + private static AttributeDefinition GetMainDamageStat(CharacterClass characterClass) + { + return characterClass.StatAttributes + .Where(a => a.Attribute == Stats.BaseStrength + || a.Attribute == Stats.BaseAgility + || a.Attribute == Stats.BaseEnergy + || a.Attribute == Stats.BaseLeadership) + .OrderByDescending(a => a.BaseValue) + .Select(a => a.Attribute!) + .FirstOrDefault() ?? Stats.BaseStrength; + } +} diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs index 14866209d..32018d7b2 100644 --- a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -6,69 +6,94 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Runtime.InteropServices; using Microsoft.Extensions.Logging; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.PlayerActions.Character; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.PlugIns; /// -/// Teaches a server-side bot the class attack skills it becomes eligible for as it levels up during play, -/// so its spell repertoire grows over its lifetime just like a real player's. Skills are only ever learned -/// for the character's own class (), gated by a per-skill learn level -/// derived from the skill's attack damage - the same rule the applies at creation. +/// Grows a server-side bot like a real player when it levels up during play: the earned stat points are +/// invested according to the bot's class build (see ), and any +/// class skill whose learn requirements (total energy, leadership, character level, ...) are now met is +/// learned - attack skills as well as the class's own buffs and heals. Skills are only ever learned for +/// the character's own class (), using the same requirements the +/// game enforces for human players, so a grown bot matches a freshly generated one of the same level. /// [PlugIn] -[Display(Name = "Bot skill progression", Description = "Teaches server-side bots new class- and level-appropriate attack skills as they level up.")] +[Display(Name = "Bot skill progression", Description = "Invests level-up stat points and teaches server-side bots new class- and level-appropriate skills as they level up.")] [Guid("D1F4A7C2-6B3E-4A59-8E71-9C0D2F5B6A84")] public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn { - /// - /// Skill-tier scaling: kept in sync with so runtime learning follows the same - /// progression a freshly generated bot of the new level would already have. - /// - private const double SkillLearnDamageFactor = 0.9; + private static readonly IncreaseStatsAction IncreaseStatsAction = new(); /// public void CharacterLeveledUp(Player player) { if (player.Account?.IsBot != true - || player.SelectedCharacter?.CharacterClass is not { } characterClass - || player.SkillList is not { } skillList) + || player.SelectedCharacter?.CharacterClass is not { } + || player.SkillList is not { }) { return; } + // Fire-and-forget from this synchronous hook; for an offline bot the involved view invocations + // are no-ops and the mutations are effectively synchronous. No own SaveChanges here - the new + // stats and skills are persisted by the periodic save, avoiding extra concurrency pressure. + _ = this.ProgressAsync(player); + } + + private async Task ProgressAsync(Player player) + { try { - var level = player.Level; - foreach (var skill in player.GameContext.Configuration.Skills) - { - if (skill.AttackDamage <= 0 - || skill.SkillType is not (SkillType.DirectHit - or SkillType.AreaSkillAutomaticHits - or SkillType.AreaSkillExplicitHits - or SkillType.AreaSkillExplicitTarget) - || !skill.QualifiedCharacters.Contains(characterClass) - || skillList.ContainsSkill((ushort)skill.Number)) - { - continue; - } + await this.SpendStatPointsAsync(player).ConfigureAwait(false); + await this.LearnNewSkillsAsync(player).ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogError(ex, "Failed to progress bot '{Name}' after level-up.", player.Name); + } + } - var learnLevel = Math.Max(1, (int)Math.Ceiling(skill.AttackDamage * SkillLearnDamageFactor)); - if (level < learnLevel) - { - continue; - } + private async ValueTask SpendStatPointsAsync(Player player) + { + var character = player.SelectedCharacter!; + var points = character.LevelUpPoints; + if (points <= 0) + { + return; + } - // For an offline bot the view invocation is a no-op and the in-memory skill maps are updated - // synchronously, so the skill is immediately available to the combat AI; the new SkillEntry is - // persisted by the periodic save (no own SaveChanges here, to avoid extra concurrency pressure). - _ = skillList.AddLearnedSkillAsync(skill); - player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, level); + var weights = BotProgression.GetStatWeights(character.CharacterClass!); + foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) + { + if (amount > 0) + { + await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false); } } - catch (Exception ex) + } + + private async ValueTask LearnNewSkillsAsync(Player player) + { + var characterClass = player.SelectedCharacter!.CharacterClass!; + var skillList = player.SkillList!; + + float? GetValue(AttributeDefinition attribute) => player.Attributes?[attribute]; + + foreach (var skill in player.GameContext.Configuration.Skills) { - player.Logger.LogError(ex, "Failed to progress bot skills for '{Name}'.", player.Name); + if (!BotProgression.IsBotLearnableSkill(skill) + || !skill.QualifiedCharacters.Contains(characterClass) + || skillList.ContainsSkill((ushort)skill.Number) + || !BotProgression.MeetsRequirements(skill, GetValue)) + { + continue; + } + + await skillList.AddLearnedSkillAsync(skill).ConfigureAwait(false); + player.Logger.LogInformation("Bot '{Name}' learned '{Skill}' at level {Level}.", player.Name, skill.Name, player.Level); } } } diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs index a62a80d89..fe717383e 100644 --- a/src/GameLogic/MuHelper/IMuHelperSettings.cs +++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs @@ -160,4 +160,33 @@ public interface IMuHelperSettings /// with class- and level-appropriate skills; human offline sessions keep their explicit configuration. /// bool AutoSelectBestSkill { get; } + + /// + /// Gets a value indicating whether the buff AI should automatically cast the learned buff skills + /// of the character, instead of relying on the explicitly configured buff slot IDs. Used by + /// server-side bots so each class keeps its own buffs up (e.g. elf Greater Defense/Greater Damage); + /// human offline sessions keep their explicit configuration. + /// + bool AutoSelectBuffs { get; } + + /// + /// Gets a value indicating whether to drink a mana potion when mana runs low, so casters can keep + /// casting. There is no client-side MU Helper setting for this; it is used by server-side bots. + /// + bool UseManaPotion { get; } + + /// + /// Gets a value indicating whether the combat AI only engages monsters the character can safely + /// handle (up to half its own level, like the bot navigator's hunting-ground selection). Without + /// this, a bot travelling through hostile territory would pick fights with monsters far above its + /// level and die. Human offline sessions keep the unrestricted behavior - the player chose the spot. + /// + bool OnlyHuntSafeMonsters { get; } + + /// + /// Gets a value indicating whether to also pick up equippable items which are an upgrade over the + /// character's currently equipped gear (evaluated before pickup), so bots progress their equipment + /// like a real player without hoarding junk. + /// + bool PickUpgradeItems { get; } } diff --git a/src/GameLogic/Offline/BuffHandler.cs b/src/GameLogic/Offline/BuffHandler.cs index 2f9fe9ec9..55227c389 100644 --- a/src/GameLogic/Offline/BuffHandler.cs +++ b/src/GameLogic/Offline/BuffHandler.cs @@ -38,7 +38,9 @@ public BuffHandler(OfflinePlayer player, IMuHelperSettings? config) } /// - /// Gets the configured buff skill IDs from the settings. + /// Gets the configured buff skill IDs from the settings. With + /// 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. /// public IList ConfiguredBuffIds { @@ -49,6 +51,24 @@ public IList ConfiguredBuffIds return []; } + if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList) + { + 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); + } + + return learnedBuffs; + } + return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; } } diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 6f210344b..92f045faf 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -19,10 +19,22 @@ public sealed class CombatHandler { private const byte DefaultRange = 1; private const byte BowRange = 6; + + /// See : fraction of the bot's own level it hunts up to. + private const float SafeMonsterFactor = 0.5f; + + /// The minimum monster level a bot will hunt, so very low-level bots still find targets. + private const int MinSafeHuntLevel = 3; private const int ComboFinisherDelayTicks = 3; private const int InterSkillDelayTicks = 1; private const int MinComboSkillCount = 3; + /// After this many consecutive failed approaches the target counts as unreachable. + private const int MaxApproachFailures = 3; + + /// How long an unreachable target is ignored before it may be considered again. + private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10); + private const short DrainLifeBaseSkillId = 214; private const short DrainLifeStrengthenerSkillId = 458; private const short DrainLifeMasterySkillId = 462; @@ -38,6 +50,11 @@ public sealed class CombatHandler 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; /// /// Initializes a new instance of the class. @@ -113,10 +130,26 @@ public async ValueTask PerformAttackAsync() 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); @@ -192,6 +225,11 @@ private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skill 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; + if (this._currentTarget is { } t && !this.IsTargetStillValid(t)) { this._currentTarget = null; @@ -200,7 +238,16 @@ private void RefreshTarget() if (this._currentTarget is null) { var monsters = this.GetAttackableMonstersInHuntingRange().ToList(); - this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player)); + + // Choose 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. + var candidates = monsters + .Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc) + .OrderBy(m => m.GetDistanceTo(this._player)) + .Take(2) + .ToList(); + this._currentTarget = candidates.SelectRandom(); this._nearbyMonsterCount = monsters.Count; } else @@ -238,9 +285,36 @@ private bool IsMonsterAttackable(Monster monster) { return monster.IsAlive && !monster.IsAtSafezone() - && monster.Definition.ObjectKind == NpcObjectKind.Monster; + && monster.Definition.ObjectKind == NpcObjectKind.Monster + && this.IsWithinSafeHuntLevel(monster); + } + + /// + /// With (server-side bots), the combat AI only + /// engages monsters up to the same safe level cap 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 above its level - and dies. Human offline sessions keep the unrestricted + /// behavior, since the player chose their hunting spot deliberately. + /// + private bool IsWithinSafeHuntLevel(Monster monster) + { + if (this._config?.OnlyHuntSafeMonsters != true) + { + return true; + } + + var botLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1); + return monster.Attributes[Stats.Level] <= GetSafeHuntCap(botLevel); } + /// + /// The strongest monster level a bot of the given level should fight. In MU a monster is far tougher + /// than a character of the same level, so bots target monsters well below their own level to survive + /// while still earning experience. Shared by the combat AI and the bot navigator, so a bot never + /// stops travelling for (or engages) a monster it should not fight. + /// + public static int GetSafeHuntCap(int botLevel) => Math.Max(MinSafeHuntLevel, (int)(botLevel * SafeMonsterFactor)); + private async ValueTask ExecutePhysicalAttackAsync(IAttackable target) { await target.AttackByAsync(this._player, null, false).ConfigureAwait(false); @@ -338,6 +412,13 @@ private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skil /// 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; @@ -367,6 +448,7 @@ or SkillType.AreaSkillExplicitHits } } + this._tickBestSkill = best; return best; } diff --git a/src/GameLogic/Offline/HealingHandler.cs b/src/GameLogic/Offline/HealingHandler.cs index 480361433..35d4ecf79 100644 --- a/src/GameLogic/Offline/HealingHandler.cs +++ b/src/GameLogic/Offline/HealingHandler.cs @@ -28,6 +28,16 @@ public sealed class HealingHandler ItemConstants.Apple, ]; + private static readonly ItemIdentifier[] ManaPotionPriority = + [ + ItemConstants.LargeManaPotion, + ItemConstants.MediumManaPotion, + ItemConstants.SmallManaPotion, + ]; + + /// Drink a mana potion once mana falls below this share, so casters can keep casting. + private const int ManaThresholdPercent = 30; + private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; @@ -75,9 +85,27 @@ await this._player.ForEachWorldObserverAsync( 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() { if (!this._config!.AutoHealParty || this._player.Party is not { } party) @@ -126,14 +154,16 @@ private bool IsHealthBelowThreshold(IAttackable target, int thresholdPercent) 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 diff --git a/src/GameLogic/Offline/ItemPickupHandler.cs b/src/GameLogic/Offline/ItemPickupHandler.cs index ea473ebcf..7acba4e51 100644 --- a/src/GameLogic/Offline/ItemPickupHandler.cs +++ b/src/GameLogic/Offline/ItemPickupHandler.cs @@ -130,6 +130,13 @@ private bool ShouldPickUp(Item item) return true; } + 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; + } + if (this._config.PickExtraItems && item.Definition is { } definition) { return this._config.ExtraItemNames.Any(name => definition.Name.ToString()?.Contains(name, StringComparison.OrdinalIgnoreCase) ?? false); diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs index 285a4c8a1..c93249f34 100644 --- a/src/GameLogic/Offline/MovementHandler.cs +++ b/src/GameLogic/Offline/MovementHandler.cs @@ -71,13 +71,16 @@ public async ValueTask RegroupAsync() /// /// The target to move closer to. /// The range to stop within. - public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) + /// True, if a walk towards the target was started; false, if no path exists or walking is not possible. + public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte range) { 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); + return await this.WalkToAsync(walkTarget).ConfigureAwait(false); } + + return false; } /// diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs index 42ad655f6..58ab6225f 100644 --- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs +++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs @@ -159,6 +159,22 @@ public sealed class MuHelperSettings : IMuHelperSettings /// Human MU Helper sessions drive their skills through the explicit client configuration, so this is always false. public bool AutoSelectBestSkill => false; + /// + /// Human MU Helper sessions configure their buff slots explicitly, so this is always false. + public bool AutoSelectBuffs => false; + + /// + /// The client-side MU Helper has no mana potion setting; this only exists for server-side bots. + public bool UseManaPotion => false; + + /// + /// A human picked their hunting spot deliberately, so their offline session fights whatever is there. + public bool OnlyHuntSafeMonsters => false; + + /// + /// Equipment progression is a server-side bot behavior only. + public bool PickUpgradeItems => false; + /// public override string ToString() { From dfa7b52a694f1f897eac0b8906f59e90db61697a Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 09:13:22 +0200 Subject: [PATCH 12/60] bots: don't unlock character classes for bot accounts A bot account animates several of its characters concurrently, each in its own persistence context with its own stale copy of the account. When two sibling characters crossed a class-unlock level at (nearly) the same time, both passed the duplicate check of UnlockCharacterAtLevelBase - neither saw the other's insert - and both added the same unlock row. From then on every periodic save of that account failed with a duplicate key on PK_AccountCharacterClass, so the affected bots stopped persisting any progress until the next server restart. Observed live with 250 bots: 17 failed saves across 5 accounts within half an hour (the Summoner unlock triggers at level 1, so every bot level-up was a candidate). Bots never create new characters, so they don't need class unlocks at all - skip them for bot accounts. Human accounts are unaffected; with one character online per account, the race cannot occur for them. Validated live: zero unlock rows and zero failed saves for bot accounts after the change (previously growing within minutes). --- .../UnlockCharacterClass/UnlockCharacterAtLevelBase.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs b/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs index 0164cc55e..27b46e5c4 100644 --- a/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs +++ b/src/GameLogic/PlugIns/UnlockCharacterClass/UnlockCharacterAtLevelBase.cs @@ -29,8 +29,13 @@ protected UnlockCharacterAtLevelBase(byte classNumber, int minimumLevel, string /// public void CharacterLeveledUp(Player player) { + // Server-side bots are excluded: they never create new characters, and animating several + // characters of one account concurrently (each with its own persistence context and thus its + // own stale copy of the account) lets two siblings pass the duplicate check below at the same + // time - both insert the same unlock row and the account's saves keep failing with a duplicate + // key of "PK_AccountCharacterClass" until the next restart. if (player.Level >= this._minimumLevel - && player.Account is { } account + && player.Account is { IsBot: false } account && account.UnlockedCharacterClasses.All(c => c.Number != this._classNumber)) { var unlockedClass = player.GameContext.Configuration.CharacterClasses.FirstOrDefault(c => c.Number == this._classNumber); From 348310732907d4483c18f6c604eadb924612ca25 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 10:49:26 +0200 Subject: [PATCH 13/60] bots: durability fixes and more human behavior Fixes: - The warp destination map is now re-resolved through the bot's own persistence context after warping; the warp assigns the global configuration instance which the bot's context does not track, so the CurrentMapId foreign key silently never saved and every restarted bot woke up on its home map and warped all over again. - Bots that have outgrown their map now warp away with priority. The nearest-monster homing always found SOMETHING huntable on the starter map, so the warp logic was never reached and high-level bots farmed level-5 mobs on Lorencia forever (validated: 0 warps in 8 minutes before, 115 in 10 minutes after, with the map population spreading out again). - Skill learning on level-up is queued into the bot's AI tick instead of running concurrently with it, so the skill list is never mutated while the combat handler enumerates it. - Archers keep their ammunition topped up (an upgraded bow can consume arrows; the starter bow does not). - The auto-buff list is cached and only rebuilt when a skill is learned - building it with LINQ on every 500ms tick of 250 bots was measurable CPU. Human behavior: - Self-defense: a new plugin notes when a player attacks a bot, and the combat AI prioritizes that aggressor over its monster targets (plain attacks only - the area-skill path deals damage exclusively to monsters). Previously a bot placidly kept farming while a player killed it. - A small randomized reaction delay before engaging a fresh target (the bot faces it first), instead of the giveaway instant metronomic strike. - Closing in on a target now walks straight along the line towards it instead of re-randomizing a nearby point every tick (visible zig-zag). - The AI tick phase is randomized per player, so hundreds of bots don't act on the same 500ms boundary. --- src/GameLogic/Bots/BotMuHelperSettings.cs | 3 +- src/GameLogic/Bots/BotNavigator.cs | 149 ++++++++++++++++-- src/GameLogic/Bots/BotSelfDefensePlugIn.cs | 35 ++++ .../Bots/BotSkillProgressionPlugIn.cs | 16 +- src/GameLogic/Offline/BuffHandler.cs | 34 ++-- src/GameLogic/Offline/CombatHandler.cs | 35 ++++ src/GameLogic/Offline/MovementHandler.cs | 29 +++- src/GameLogic/Offline/OfflinePlayer.cs | 61 +++++++ .../Offline/OfflinePlayerMuHelper.cs | 8 + 9 files changed, 336 insertions(+), 34 deletions(-) create mode 100644 src/GameLogic/Bots/BotSelfDefensePlugIn.cs diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index 61f43bf47..a2563547a 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -160,7 +160,8 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings public bool RepairItem => false; /// - public bool UseSelfDefense => false; + /// Bots fight back when a player attacks them (see ). + public bool UseSelfDefense => true; /// public bool AutoAcceptFriend => false; diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 14fcc9021..e9c22b237 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Threading; using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.Persistence; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Offline; @@ -281,6 +282,16 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // A bot that has OUTGROWN its map warps away with priority - even though trivial monsters are + // still around. Without this, the nearest-monster homing always finds SOMETHING huntable on the + // starter map, the warp logic below is never reached, and high-level bots farm level-5 mobs on + // Lorencia forever (with the experience penalty) instead of moving on to their level's maps. + if (IsMapOutgrown(map.Definition, this.GetBotLevel()) + && await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) + { + return; + } + // Inside the safezone the bot can't fight anyway, so it must keep walking out of town instead of // freezing at the gate when a monster is just outside. Only stop to fight once it has left the safezone. if (!inSafezone && monstersNearby > 0) @@ -342,30 +353,54 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._emptyGroundSince = null; - // Warp to the map that offers the strongest monsters this bot can still safely handle, so it - // earns the best experience without being slaughtered. It arrives at the destination safezone - // and walks out to a hunting ground like a real player. - var botLevel = this.GetBotLevel(); - if (botLevel >= MinWarpLevel - && DateTime.UtcNow - this._lastWarpUtc >= WarpCooldown - && this.TryPickBetterMap(botLevel, out var targetGate, out var targetMap, out var targetLevel)) + if (await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) { - this._lastWarpUtc = DateTime.UtcNow; - this._hasDestination = false; - this._travelPath = null; - this._player.Logger.LogInformation( - "Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).", - this._player.Name, - botLevel, - targetMap.Name, - targetLevel); - await this._player.WarpToAsync(targetGate).ConfigureAwait(false); return; } await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); } + /// + /// Warps to the map that offers the strongest monsters this bot can still safely handle, so it + /// earns the best experience without being slaughtered. It arrives at the destination safezone + /// and walks out to a hunting ground like a real player. + /// + /// True, if a warp was performed. + private async ValueTask TryWarpToBetterMapAsync() + { + var botLevel = this.GetBotLevel(); + if (botLevel < MinWarpLevel + || DateTime.UtcNow - this._lastWarpUtc < WarpCooldown + || !this.TryPickBetterMap(botLevel, out var targetGate, out var targetMap, out var targetLevel)) + { + return false; + } + + this._lastWarpUtc = DateTime.UtcNow; + this._hasDestination = false; + this._travelPath = null; + this._player.Logger.LogInformation( + "Bot {Character} (level {Level}) warping to map {Map} (monsters ~{MonsterLevel}).", + this._player.Name, + botLevel, + targetMap.Name, + targetLevel); + await this._player.WarpToAsync(targetGate).ConfigureAwait(false); + await this.TryPersistCurrentMapAsync(targetMap).ConfigureAwait(false); + return true; + } + + /// + /// Determines whether the bot has outgrown the given map: the strongest monster it could safely + /// hunt anywhere is meaningfully above the strongest one this map has to offer. + /// + private static bool IsMapOutgrown(GameMapDefinition mapDefinition, int botLevel) + { + var cap = GetHuntCap(botLevel); + return BestHuntableLevel(mapDefinition, cap) + WarpImprovementMargin < cap; + } + /// /// Walks the next stretch towards the destination. A full route is resolved with a heuristic search /// (so it goes around walls and out of the town gate) and the first steps are @@ -484,6 +519,57 @@ private async ValueTask EnsurePotionsAsync() { await this.EnsurePotionStackAsync(HealPotionNumber).ConfigureAwait(false); await this.EnsurePotionStackAsync(ManaPotionNumber).ConfigureAwait(false); + await this.EnsureAmmunitionAsync().ConfigureAwait(false); + } + + /// + /// Keeps an archer's ammunition topped up. With the starter bow the offline attack path does not + /// consume arrows, but an upgraded bow can carry an ammunition consumption rate - without this the + /// bot would eventually shoot its quiver empty and its bow skills would stop working. + /// + private async ValueTask EnsureAmmunitionAsync() + { + if (this._player.Inventory is not { } inventory) + { + return; + } + + if (inventory.EquippedAmmunitionItem is { } ammo) + { + if (ammo.Durability < HealPotionRefillBelow) + { + ammo.Durability = HealPotionStack; + } + + return; + } + + // Ammo ran out completely (an empty stack gets destroyed): if a bow is equipped, put a fresh + // stack of arrows into the other hand. + var bow = inventory.EquippedItems.FirstOrDefault(i => + i.Definition is { Group: 4, IsAmmunition: false }); + if (bow is null) + { + return; + } + + var arrows = this._player.GameContext.Configuration.Items + .FirstOrDefault(d => d.Group == 4 && d.Number == 15); + if (arrows is null) + { + return; + } + + var item = this._player.PersistenceContext.CreateNew(); + item.Definition = arrows; + item.Durability = HealPotionStack; + var targetSlot = bow.ItemSlot == InventoryConstants.RightHandSlot + ? InventoryConstants.LeftHandSlot + : InventoryConstants.RightHandSlot; + if (!await inventory.AddItemAsync(targetSlot, item).ConfigureAwait(false)) + { + await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + } } private async ValueTask EnsurePotionStackAsync(int potionNumber) @@ -599,6 +685,35 @@ private bool TryFindNearestMonsterGround(GameMap map, out Point ground) return true; } + /// + /// Re-resolves the warp destination map through the bot's own persistence context and assigns that + /// instance to the character. The warp itself assigns the GLOBAL configuration instance, which the + /// bot's context does not track - the CurrentMapId foreign key silently never got saved, so a + /// restarted bot always woke up on its home map and warped all over again (warp burst on restart). + /// + private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinition) + { + try + { + if (this._player.SelectedCharacter is not { } character) + { + return; + } + + var ownInstance = await this._player.PersistenceContext + .GetByIdAsync(mapDefinition.GetId()).ConfigureAwait(false); + if (ownInstance is not null) + { + character.CurrentMap = ownInstance; + } + } + catch (Exception ex) + { + // Not worth failing the warp over - worst case the map is (as before) not persisted. + this._player.Logger.LogDebug(ex, "Could not persist current map for bot {Character}.", this._player.Name); + } + } + private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); /// The highest monster level a bot of the given level should fight - shared with the combat AI. diff --git a/src/GameLogic/Bots/BotSelfDefensePlugIn.cs b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs new file mode 100644 index 000000000..606704768 --- /dev/null +++ b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs @@ -0,0 +1,35 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Notes when a (human) player attacks a server-side bot, so the bot's combat AI can defend itself. +/// Without this, a bot placidly keeps farming monsters while a player kills it - the most obviously +/// bot-like behavior an observer can trigger. The actual counter-attack happens in the offline +/// , which prioritizes a recent aggressor over its monster targets. +/// +[PlugIn] +[Display(Name = "Bot self defense", Description = "Makes server-side bots fight back when a player attacks them.")] +[Guid("7E2B9C41-5A8D-4F36-B190-3D6E84C7F215")] +public class BotSelfDefensePlugIn : IAttackableGotHitPlugIn +{ + /// + public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo hitInfo) + { + if (attackable is OfflinePlayer bot + && bot.Account?.IsBot == true + && attacker is Player aggressor + && aggressor is not OfflinePlayer + && !ReferenceEquals(aggressor, attackable)) + { + bot.RegisterAggressor(aggressor); + } + } +} diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs index 32018d7b2..c38968b62 100644 --- a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -37,10 +37,18 @@ public void CharacterLeveledUp(Player player) return; } - // Fire-and-forget from this synchronous hook; for an offline bot the involved view invocations - // are no-ops and the mutations are effectively synchronous. No own SaveChanges here - the new - // stats and skills are persisted by the periodic save, avoiding extra concurrency pressure. - _ = this.ProgressAsync(player); + // Queue the progression into the bot's AI tick instead of running it right here: this hook fires + // from the experience/level-up path while the combat handler may be enumerating the skill list on + // its own timer - the tick serializes both. No own SaveChanges here - the new stats and skills + // are persisted by the periodic save, avoiding extra concurrency pressure. + if (player is Offline.OfflinePlayer offlinePlayer) + { + offlinePlayer.PendingBotActions.Enqueue(() => new ValueTask(this.ProgressAsync(offlinePlayer))); + } + else + { + _ = this.ProgressAsync(player); + } } private async Task ProgressAsync(Player player) diff --git a/src/GameLogic/Offline/BuffHandler.cs b/src/GameLogic/Offline/BuffHandler.cs index 55227c389..3beaeec99 100644 --- a/src/GameLogic/Offline/BuffHandler.cs +++ b/src/GameLogic/Offline/BuffHandler.cs @@ -25,6 +25,8 @@ public sealed class BuffHandler private int _nextSlotIndex; private bool _buffTimerTriggered; private DateTime? _nextPeriodicBuffTime; + private IList? _cachedAutoBuffIds; + private int _cachedAutoBuffSkillCount = -1; /// /// Initializes a new instance of the class. @@ -53,20 +55,30 @@ public IList ConfiguredBuffIds if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList) { - 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) + // 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) { - learnedBuffs.Add(0); + 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 learnedBuffs; + return this._cachedAutoBuffIds; } return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 92f045faf..d649ca8fa 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -55,6 +55,7 @@ public sealed class CombatHandler private DateTime _unreachableTargetUntilUtc = DateTime.MinValue; private SkillEntry? _tickBestSkill; private bool _tickBestSkillComputed; + private DateTime _engageAtUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -120,6 +121,7 @@ public void DecrementCooldown() /// public async ValueTask PerformAttackAsync() { + var previousTarget = this._currentTarget; this.RefreshTarget(); if (this._currentTarget is null) @@ -127,6 +129,20 @@ public async ValueTask PerformAttackAsync() 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. + 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)) { @@ -207,6 +223,14 @@ private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skill { this._player.Rotation = this._player.GetDirectionTo(target); + if (target is Player) + { + // Self-defense against a player: plain attacks only. The area-skill path below deals its + // damage exclusively to monsters, so a skill cast would look flashy but hit nothing. + await this.ExecutePhysicalAttackAsync(target).ConfigureAwait(false); + return; + } + if (skillEntry?.Skill is not { } skill) { await this.ExecutePhysicalAttackAsync(target).ConfigureAwait(false); @@ -230,6 +254,17 @@ private void RefreshTarget() 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. + if (this._config?.UseSelfDefense == true + && this._player.RecentAggressor is { } aggressor + && aggressor.IsInRange(this._player.Position, this.HuntingRange * 2)) + { + this._currentTarget = aggressor; + return; + } + if (this._currentTarget is { } t && !this.IsTargetStillValid(t)) { this._currentTarget = null; diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs index c93249f34..a52b2460d 100644 --- a/src/GameLogic/Offline/MovementHandler.cs +++ b/src/GameLogic/Offline/MovementHandler.cs @@ -76,13 +76,40 @@ public async ValueTask MoveCloserToTargetAsync(IAttackable target, byte ra { if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange)) { - var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range); + var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range); return await this.WalkToAsync(walkTarget).ConfigureAwait(false); } return false; } + /// + /// 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. + /// + 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); + } + /// /// Walks the player to the specified target position. /// diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 2a60626e7..88e2e9adc 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -49,6 +49,67 @@ public OfflinePlayer(IGameContext gameContext) /// public virtual bool RespawnAndContinue => false; + /// + /// Gets actions queued from outside the AI tick (e.g. skill learning on level-up), which the + /// 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. + /// + internal System.Collections.Concurrent.ConcurrentQueue> PendingBotActions { get; } = new(); + + /// How long an attack by a player stays "hot" as a self-defense target. + private static readonly TimeSpan AggressionMemory = TimeSpan.FromSeconds(15); + + private Player? _lastAggressor; + private DateTime _lastAggressionUtc; + + /// + /// 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. + /// + internal Player? RecentAggressor + { + get + { + if (this._lastAggressor is { } aggressor + && DateTime.UtcNow - this._lastAggressionUtc <= AggressionMemory + && aggressor.IsAlive + && !aggressor.IsAtSafezone()) + { + return aggressor; + } + + return null; + } + } + + /// + /// Registers a player who attacked this bot, so the combat AI can defend itself. + /// + internal void RegisterAggressor(Player aggressor) + { + this._lastAggressor = aggressor; + this._lastAggressionUtc = DateTime.UtcNow; + } + + /// + /// Executes and removes all queued . + /// + 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); + } + } + } + /// /// Initializes the offline player by loading the account fresh from the database. /// diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 2f071b015..21cedd2f4 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -126,6 +126,10 @@ private void OnPlayerDied(DeathInformation e) 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(); @@ -151,6 +155,10 @@ private async Task SafeTickAsync(CancellationToken cancellationToken) 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; From af0620346bf624dbadff5008c0fd57e0fd0e44f9 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 11:45:47 +0200 Subject: [PATCH 14/60] bots: hunt in small parties like real player groups A share (~60%) of the bots now forms hunting parties of 2-5 level-wise similar characters at startup, through the regular party manager. The party leader is the lowest-level member, so it only ever picks maps the whole group can hunt on; the other members follow it - warping to the leader's map when it warped away (with a short cooldown, so the group regroups quickly) and walking back to the leader when they drift more than a few tiles off. Near the leader they hunt normally; followers never warp on their own. With the group staying together, the already existing offline party mechanics kick in: the elf heals party members below threshold, buffs are shared with the party, and the party experience bonus applies - a group of bots now reads like a group of players. Validated live: 58 parties formed, members converged on their leaders across maps (e.g. follower and leader 6 tiles apart on Elvenland), no new error kinds, resources unchanged (~37% of one core, 680 MB for 250 bots). --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 9 +++ src/GameLogic/Bots/BotManager.cs | 64 ++++++++++++++++++ src/GameLogic/Bots/BotMuHelperSettings.cs | 8 ++- src/GameLogic/Bots/BotNavigator.cs | 82 ++++++++++++++++++++++- 4 files changed, 158 insertions(+), 5 deletions(-) diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 028b174e9..8536c4299 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -135,6 +135,15 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total); + + try + { + await this._botManager.FormPartiesAsync(gameContext).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to form bot parties."); + } } /// diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index eec3c52aa..170ef25af 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Linq; +using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Offline; /// @@ -120,6 +121,69 @@ public async ValueTask StopAllAsync() } } + /// + /// Groups a share of the active bots into small hunting parties of level-wise similar characters, + /// like real players do: the party members follow their leader (see the follow logic in + /// ), the elf heals the group, buffs are shared and the party experience + /// bonus applies. The rest of the bots keep hunting solo, so the population stays varied. + /// + /// The game context (provides the party manager). + public async ValueTask FormPartiesAsync(IGameContext gameContext) + { + const int minPartySize = 2; + const int maxPartySize = 5; + const int maxLevelGap = 12; + const int partiedSharePercent = 60; + + var candidates = this._bots.Values + .Where(b => b.Party is null && b.Attributes is not null) + .OrderBy(b => b.Attributes![Stats.Level]) + .ToList(); + + var index = 0; + while (index < candidates.Count - 1) + { + if (Rand.NextInt(0, 100) >= partiedSharePercent) + { + index++; // this bot stays solo + continue; + } + + var leader = candidates[index]; + var leaderLevel = (int)leader.Attributes![Stats.Level]; + var targetSize = Rand.NextInt(minPartySize, maxPartySize + 1); + var members = new List { leader }; + var next = index + 1; + while (next < candidates.Count + && members.Count < targetSize + && (int)candidates[next].Attributes![Stats.Level] - leaderLevel <= maxLevelGap) + { + members.Add(candidates[next]); + next++; + } + + if (members.Count >= minPartySize) + { + var party = gameContext.PartyManager.CreateParty(); + foreach (var member in members) + { + if (!await party.AddAsync(member).ConfigureAwait(false)) + { + break; + } + } + + leader.Logger.LogInformation( + "Formed bot party of {Count} around '{Leader}' (level {Level}).", + members.Count, + leader.Name, + leaderLevel); + } + + index = next; + } + } + private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}"; private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot) diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index a2563547a..f9c20ea5a 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -109,13 +109,15 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings public int PotionThresholdPercent => 60; /// - public bool SupportParty => false; + // Bots hunt in small parties (see BotManager.FormParties): the elf heals the group, buffs are + // shared, and the party experience bonus applies - like a real group of players. + public bool SupportParty => true; /// - public bool AutoHealParty => false; + public bool AutoHealParty => true; /// - public int HealPartyThresholdPercent => 0; + public int HealPartyThresholdPercent => 60; /// public bool UseDarkRaven => false; diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index e9c22b237..fdbf17f05 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -98,6 +98,12 @@ internal sealed class BotNavigator : AsyncDisposable /// Minimum time between two cross-map warps, so a bot does not bounce between maps. private static readonly TimeSpan WarpCooldown = TimeSpan.FromSeconds(60); + /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly). + private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20); + + /// A party member keeps within this distance (tiles) of its leader; beyond it, it walks back. + private const int FollowDistance = 10; + /// /// If the bot's position has not changed for this long it is considered stuck (a wedged walk, or a /// monster it can't reach). The navigator then forces a fresh destination so the bot gets going again. @@ -282,11 +288,26 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // Party members follow their leader instead of roaming on their own: to the leader's map when + // it warped away, and towards the leader when it moved off. Near the leader they hunt normally + // (the party heal/buff handlers and the party experience bonus need the group to stay together). + // Followers never warp on their own - the leader (the lowest-level member, so it only ever picks + // maps the whole group can hunt on) decides where the party goes. + var leaderToFollow = this.GetPartyLeaderToFollow(); + if (leaderToFollow is not null) + { + if (await this.TryFollowLeaderAsync(map, leaderToFollow).ConfigureAwait(false)) + { + return; + } + } + // A bot that has OUTGROWN its map warps away with priority - even though trivial monsters are // still around. Without this, the nearest-monster homing always finds SOMETHING huntable on the // starter map, the warp logic below is never reached, and high-level bots farm level-5 mobs on // Lorencia forever (with the experience penalty) instead of moving on to their level's maps. - if (IsMapOutgrown(map.Definition, this.GetBotLevel()) + if (leaderToFollow is null + && IsMapOutgrown(map.Definition, this.GetBotLevel()) && await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) { return; @@ -353,7 +374,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._emptyGroundSince = null; - if (await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) + if (leaderToFollow is null && await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) { return; } @@ -361,6 +382,63 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); } + /// + /// Gets the party leader this bot should follow, or null if the bot is solo, is the leader itself, + /// or the leader is currently not followable (dead, no map). + /// + private Player? GetPartyLeaderToFollow() + { + if (this._player.Party is not { } party + || party.PartyMaster is not Player leader + || ReferenceEquals(leader, this._player) + || !leader.IsAlive + || leader.CurrentMap is null) + { + return null; + } + + return leader; + } + + /// + /// Keeps a party member with its leader: warps to the leader's map when the leader warped away, + /// and walks towards the leader when it moved out of the follow range. + /// + /// True, if following consumed this tick; false, if the bot is close enough and should hunt normally. + private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) + { + if (!ReferenceEquals(leader.CurrentMap, map)) + { + if (DateTime.UtcNow - this._lastWarpUtc >= FollowWarpCooldown + && leader.CurrentMap is { } leaderMap + && leaderMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } leaderGate) + { + this._lastWarpUtc = DateTime.UtcNow; + this._hasDestination = false; + this._travelPath = null; + this._player.Logger.LogInformation( + "Bot {Character} following party leader {Leader} to map {Map}.", + this._player.Name, + leader.Name, + leaderMap.Definition.Name); + await this._player.WarpToAsync(leaderGate).ConfigureAwait(false); + await this.TryPersistCurrentMapAsync(leaderMap.Definition).ConfigureAwait(false); + } + + return true; + } + + if (this._player.GetDistanceTo(leader.Position) > FollowDistance) + { + this._destination = leader.Position; + this._hasDestination = true; + await this.TravelTowardAsync(map, leader.Position).ConfigureAwait(false); + return true; + } + + return false; + } + /// /// Warps to the map that offers the strongest monsters this bot can still safely handle, so it /// earns the best experience without being slaughtered. It arrives at the destination safezone From b3ce0551e28cedba8f63c8faaa1957d1c0fdd900 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 12:05:49 +0200 Subject: [PATCH 15/60] bots: presence rotation over the day Instead of the same 250 characters being online 24/7 - a giveaway no real server has - the bot population now ebbs and flows with the time of day: an hourly activity curve (quietest in the early morning, busiest in the evening) scales the online share between a configurable minimum (default 60%) and 100%. A maintenance pass adjusts by at most one bot per minute, so logins and logouts trickle in smoothly. A rotated-out bot leaves its party cleanly and disconnects like a regular logout (saving its progress); parties are re-formed hourly, so bots which rotated back in (or lost their leader) get grouped again. Validated live: population converged from 250 towards the midday target of 200 at exactly one logout per minute, with clean party exits and no new error kinds. --- src/GameLogic/Bots/BotConfiguration.cs | 15 ++++ src/GameLogic/Bots/BotFeaturePlugIn.cs | 110 ++++++++++++++++++++++++- src/GameLogic/Bots/BotManager.cs | 39 +++++++++ 3 files changed, 163 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs index 6fb35c772..61a0b7081 100644 --- a/src/GameLogic/Bots/BotConfiguration.cs +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -31,6 +31,21 @@ public class BotConfiguration [Display(Name = "Reset bots", Description = "Deletes all bot accounts and characters on the next start, then regenerates them. Clears itself afterwards.")] public bool ResetBots { get; set; } + /// + /// Gets or sets a value indicating whether the bot population rotates its presence over the day: + /// fewer bots are online at night, most in the evening, with bots smoothly logging in and out - + /// like a real player base, instead of the same characters being online 24/7. + /// + [Display(Name = "Presence rotation", Description = "Bots log in and out over the day (fewest at night, most in the evening) instead of all being online 24/7.")] + public bool PresenceRotation { get; set; } = true; + + /// + /// Gets or sets the share (in percent) of bots which stays online at the quietest time of day. + /// 100 effectively disables the rotation effect. + /// + [Display(Name = "Min. online share %", Description = "Percentage of the bot population which stays online at the quietest hour (100 = no rotation effect).")] + public int MinOnlineSharePercent { get; set; } = 60; + /// /// Gets or sets the number of bot accounts. Together with /// this defines the bot population, e.g. 10 accounts × 5 characters = 50 bot characters. diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 8536c4299..4a862a74d 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -28,7 +28,12 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus private readonly BotManager _botManager = new(); + private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60); + private DateTime _nextRunUtc = DateTime.UtcNow + StartupDelay; + private DateTime _nextMaintenanceUtc = DateTime.UtcNow + StartupDelay + StartupDelay; + private DateTime _nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; private bool _spawned; /// @@ -42,7 +47,13 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { - if (this._spawned || DateTime.UtcNow < this._nextRunUtc) + if (this._spawned) + { + await this.RunMaintenanceAsync(gameContext).ConfigureAwait(false); + return; + } + + if (DateTime.UtcNow < this._nextRunUtc) { return; } @@ -146,6 +157,103 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } } + /// + /// The typical activity of a player base by local hour (0..1): quietest in the early morning, + /// busiest in the evening. Scales between + /// and 100% of the bot population. + /// + private static readonly double[] ActivityByHour = + [ + 0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45, + 0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50, + ]; + + /// + /// Runs the periodic post-spawn maintenance: the presence rotation (one bot in or out per pass, so + /// the population ebbs and flows smoothly over the day) and an hourly party re-formation which + /// groups bots that lost or never had a party (e.g. after rotating back in). + /// + private async ValueTask RunMaintenanceAsync(GameContext gameContext) + { + if (DateTime.UtcNow < this._nextMaintenanceUtc) + { + return; + } + + this._nextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; + + var configuration = this.Configuration; + if (configuration?.Enabled != true) + { + return; + } + + var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); + try + { + if (configuration.PresenceRotation) + { + await this.RotatePresenceAsync(gameContext, configuration, logger).ConfigureAwait(false); + } + + if (DateTime.UtcNow >= this._nextPartyReformUtc) + { + this._nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; + await this._botManager.FormPartiesAsync(gameContext).ConfigureAwait(false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Bot maintenance failed."); + } + } + + private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger) + { + var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount(); + var totalPopulation = configuration.NumberOfAccounts * charactersPerAccount; + if (totalPopulation <= 0) + { + return; + } + + var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0; + var activity = ActivityByHour[DateTime.Now.Hour]; + var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity))); + var online = this._botManager.Bots.Count; + + if (online < targetOnline) + { + // Bring one bot online: pick a random character which is currently offline. + var offline = new List<(string Login, byte Slot)>(); + for (var i = 1; i <= configuration.NumberOfAccounts; i++) + { + var loginName = BotGenerator.GetLoginName(i); + for (byte slot = 0; slot < charactersPerAccount; slot++) + { + if (!this._botManager.IsActive(loginName, slot)) + { + offline.Add((loginName, slot)); + } + } + } + + if (offline.SelectRandom() is { Login: not null } candidate + && await this._botManager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false)) + { + logger.LogInformation("Bot presence rotation: +1 (online {Online}/{Target} of {Total}).", online + 1, targetOnline, totalPopulation); + } + } + else if (online > targetOnline) + { + var stopped = await this._botManager.StopRandomBotAsync().ConfigureAwait(false); + if (stopped is not null) + { + logger.LogInformation("Bot presence rotation: -1 '{Name}' (online {Online}/{Target} of {Total}).", stopped, online - 1, targetOnline, totalPopulation); + } + } + } + /// public void ForceStart() { diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index 170ef25af..30964cb1f 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -121,6 +121,45 @@ public async ValueTask StopAllAsync() } } + /// + /// Determines whether the given character of the given account is currently animated by a bot. + /// + /// The account login name. + /// The character slot. + public bool IsActive(string loginName, byte slot) => this._bots.ContainsKey(GetKey(loginName, slot)); + + /// + /// Stops one randomly chosen bot (used by the presence rotation, so the population ebbs and flows + /// like a real player base). The bot leaves its party cleanly first, then disconnects - which also + /// saves its progress, like a regular logout. + /// + /// The name of the stopped bot's character, or null if no bot was active. + public async ValueTask StopRandomBotAsync() + { + var key = this._bots.Keys.ToList().SelectRandom(); + if (key is null || !this._bots.TryRemove(key, out var bot)) + { + return null; + } + + var name = bot.Name; + try + { + if (bot.Party is { } party) + { + await party.KickMySelfAsync(bot).ConfigureAwait(false); + } + + await bot.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + bot.Logger.LogError(ex, "Error while stopping bot '{Key}' for presence rotation.", key); + } + + return name; + } + /// /// Groups a share of the active bots into small hunting parties of level-wise similar characters, /// like real players do: the party members follow their leader (see the follow logic in From 8b1636798991949f87d515bfa76e9a7529955da4 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 13:42:06 +0200 Subject: [PATCH 16/60] bots: town economy - sell junk loot, buy potions with zen Bots now trade with town merchants like real players instead of materializing supplies out of thin air. When the backpack fills up with sellable junk or a potion kind runs low, the bot heads to a merchant (warping to the map's safezone first when out in the field, like using a town portal), walks up to it and trades through the regular player actions: junk gear is sold for Zen (jewels and excellent/ancient pieces stay - that's the bot's wealth), and healing/mana potions are bought with Zen up to a stock target. While the shop dialog is open, the player state pauses the combat AI - the bot visibly shops. The out-of-thin-air top-up remains only as an emergency fallback at a much lower threshold, so a broke bot far from town still never dies over an empty bottle. Freshly generated bots start with only a handful of potion charges and their starting Zen, so the shopping economy kicks in from minute one. Validated live with 250 bots: 249 shopping trips at Potion Girl Amy on the first wave, a second wave restocking to ~100-150 charges per kind for real Zen (~8k per stack of ~30), no errors in the NPC dialog flow. --- src/GameLogic/Bots/BotGenerator.cs | 6 +- src/GameLogic/Bots/BotNavigator.cs | 83 +++++++- src/GameLogic/Bots/BotShoppingHandler.cs | 232 +++++++++++++++++++++++ src/GameLogic/GameMap.cs | 11 ++ 4 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 src/GameLogic/Bots/BotShoppingHandler.cs diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 3ddfb9a63..5f362454d 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -436,7 +436,11 @@ private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte var item = context.CreateNew(); item.Definition = potion; - item.Durability = 255; + + // Only a handful of charges to start with: fresh bots head to the merchant right away and buy + // their supplies with their starting Zen, kicking off the shopping economy from minute one + // (kept just above the emergency top-up threshold, so the economy path - not the fallback - runs). + item.Durability = Rand.NextInt(10, 16); item.ItemSlot = slot; inventory.Items.Add(item); } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index fdbf17f05..55832bf60 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -62,8 +62,13 @@ internal sealed class BotNavigator : AsyncDisposable /// Stack size (the potion item's Durability holds the remaining count; one is spent per heal). private const byte HealPotionStack = 255; - /// Refill the stack once it drops below this, so the bot never runs out mid-fight. - private const int HealPotionRefillBelow = 30; + /// + /// Emergency refill threshold: normally the bot restocks potions by BUYING them at a merchant (see + /// , triggered well above this level); only when a stack still runs + /// this low (e.g. no Zen, merchant unreachable) is it topped up out of thin air as a last resort, + /// so the bot never dies over an empty bottle. + /// + private const int HealPotionRefillBelow = 10; /// Number of path steps to issue per travel hop. Short hops let the bot stop and fight between hops. private const int StepsPerHop = 3; @@ -78,6 +83,15 @@ internal sealed class BotNavigator : AsyncDisposable /// How often the bot checks its backpack for equippable upgrades. private static readonly TimeSpan EquipCheckInterval = TimeSpan.FromSeconds(8); + /// How often the bot considers whether it needs a shopping trip. + private static readonly TimeSpan ShoppingCheckInterval = TimeSpan.FromSeconds(30); + + /// Cooldown after a shopping trip before considering the next one. + private static readonly TimeSpan ShoppingCooldown = TimeSpan.FromMinutes(10); + + /// How close (tiles) the bot must be to the merchant to trade. + private const int MerchantTalkRange = 3; + /// /// If a monster is within this range the bot stops travelling and lets the combat handler engage. /// MUST equal the combat hunting range: the combat handler only ever targets monsters within @@ -146,6 +160,8 @@ internal sealed class BotNavigator : AsyncDisposable private IList? _travelPath; private int _travelPathIndex; private Point _travelPathTarget; + private Point? _shoppingTarget; + private DateTime _nextShoppingCheckUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -288,6 +304,14 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // A shopping trip (selling junk loot, restocking potions with Zen) runs before everything else, + // so it completes even for party members - the follow logic below would otherwise pull them + // back to the leader mid-trade. + if (await this.TryShoppingAsync(map, inSafezone).ConfigureAwait(false)) + { + return; + } + // Party members follow their leader instead of roaming on their own: to the leader's map when // it warped away, and towards the leader when it moved off. Near the leader they hunt normally // (the party heal/buff handlers and the party experience bonus need the group to stay together). @@ -382,6 +406,61 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); } + /// + /// Drives a shopping trip: when the backpack fills up or potions run low, the bot heads to a town + /// merchant (warping to the map's safezone first when out in the field, like using a town portal), + /// walks up to it and trades - selling junk loot and restocking potions with Zen. + /// + /// True, if the shopping trip consumed this tick. + private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) + { + if (this._shoppingTarget is { } target) + { + if (this._player.GetDistanceTo(target) > MerchantTalkRange) + { + if (!await this.TravelTowardAsync(map, target).ConfigureAwait(false)) + { + // No way to the merchant from here - give up this trip. + this._shoppingTarget = null; + } + + return true; + } + + await BotShoppingHandler.TryTradeAsync(this._player, map, target).ConfigureAwait(false); + this._shoppingTarget = null; + this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown; + this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck" + return true; + } + + if (DateTime.UtcNow < this._nextShoppingCheckUtc) + { + return false; + } + + this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCheckInterval; + if (!BotShoppingHandler.NeedsShopping(this._player) + || BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition) + { + return false; + } + + // Merchants stand in town: when out in the field, warp to the map's safezone first (like using + // a town portal scroll), then walk over to the merchant. + if (!inSafezone + && map.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } townGate) + { + this._travelPath = null; + this._hasDestination = false; + await this._player.WarpToAsync(townGate).ConfigureAwait(false); + } + + this._shoppingTarget = merchantPosition; + this._player.Logger.LogInformation("Bot '{Name}' heads to the merchant for a shopping trip.", this._player.Name); + return true; + } + /// /// Gets the party leader this bot should follow, or null if the bot is solo, is the leader itself, /// or the leader is currently not followable (dead, no map). diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs new file mode 100644 index 000000000..4cd611fcb --- /dev/null +++ b/src/GameLogic/Bots/BotShoppingHandler.cs @@ -0,0 +1,232 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; +using MUnique.OpenMU.Pathfinding; + +/// +/// Lets a bot trade with town merchants like a real player: when the backpack silts up or the potions +/// run low, the bot visits a merchant, sells its junk loot for Zen and buys potion refills with it - +/// closing the economic loop (materializing supplies out of thin air stays only as an emergency +/// fallback, see the low threshold in ). The trade uses the regular player +/// actions (, , ), +/// and while the dialog is open the player state pauses the combat AI - the bot visibly "shops". +/// +internal static class BotShoppingHandler +{ + /// Start a shopping trip when fewer free backpack slots than this remain. + private const int FreeSlotPressure = 20; + + /// Start a shopping trip when a potion kind has fewer charges than this. + private const int PotionLowThreshold = 40; + + /// + /// Stop buying refills once this many charges are stocked. Merchants sell potions by the piece, + /// so this target keeps a trip affordable (roughly a bot's income between two trips) while lasting + /// a good while of hunting. + /// + private const int PotionTargetCharges = 60; + + /// Maximum purchases per potion kind per trip. + private const int MaxPurchasesPerKind = 20; + + private static readonly TalkNpcAction TalkAction = new(); + private static readonly SellItemToNpcAction SellAction = new(); + private static readonly BuyNpcItemAction BuyAction = new(); + private static readonly CloseNpcDialogAction CloseAction = new(); + + private static readonly ItemIdentifier[] Valuables = + [ + ItemConstants.JewelOfChaos, + ItemConstants.JewelOfBless, + ItemConstants.JewelOfSoul, + ItemConstants.JewelOfLife, + ItemConstants.JewelOfCreation, + ItemConstants.JewelOfGuardian, + ItemConstants.Gemstone, + ItemConstants.JewelOfHarmony, + ItemConstants.LowerRefineStone, + ItemConstants.HigherRefineStone, + ]; + + /// + /// Determines whether the bot should go shopping: the backpack is filling up with sellable junk, + /// or a potion stack is running low (and there is Zen to restock with). + /// + public static bool NeedsShopping(OfflinePlayer player) + { + if (player.Inventory is not { } inventory) + { + return false; + } + + var freeSlots = inventory.FreeSlots.Count(); + if (freeSlots < FreeSlotPressure && inventory.Items.Any(IsSellableJunk)) + { + return true; + } + + if (player.Money > 5000 && GetLowPotionKinds(player).Any()) + { + return true; + } + + return false; + } + + /// + /// Finds the position of a merchant NPC on the map (from the map's spawn configuration; merchants + /// are stationary), preferring one which sells potions. + /// + public static Point? FindMerchantPosition(GameMap map) + { + MonsterSpawnArea? best = null; + foreach (var spawn in map.Definition.MonsterSpawns) + { + if (spawn.MonsterDefinition is not { ObjectKind: NpcObjectKind.PassiveNpc } definition + || definition.MerchantStore is not { Items.Count: > 0 } store) + { + continue; + } + + var sellsPotions = store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6); + if (best is null || sellsPotions) + { + best = spawn; + if (sellsPotions) + { + break; + } + } + } + + return best is null ? null : new Point(best.X1, best.Y1); + } + + /// + /// Performs the actual trade with the merchant standing near the given position: opens the dialog, + /// sells the junk loot, buys potion refills and closes the dialog again. + /// + /// True, if a merchant was found and the trade ran; false if no merchant is there. + public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap map, Point merchantPosition) + { + var merchant = map.GetNpcsInRange(merchantPosition, 4) + .FirstOrDefault(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 }); + if (merchant is null || player.Inventory is not { } inventory) + { + return false; + } + + await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false); + if (player.OpenedNpc is null) + { + return false; + } + + try + { + var soldCount = 0; + foreach (var junk in inventory.Items.Where(IsSellableJunk).ToList()) + { + await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false); + soldCount++; + } + + var boughtCount = 0; + var store = player.OpenedNpc.Definition.MerchantStore; + foreach (var potionNumber in GetLowPotionKinds(player)) + { + var storeItem = store?.Items.FirstOrDefault(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber); + if (storeItem is null) + { + continue; + } + + for (var i = 0; i < MaxPurchasesPerKind && GetPotionCharges(player, potionNumber) < PotionTargetCharges; i++) + { + var moneyBefore = player.Money; + await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false); + if (player.Money >= moneyBefore) + { + break; // purchase failed (no money / no space) + } + + boughtCount++; + } + } + + if (soldCount > 0 || boughtCount > 0) + { + player.Logger.LogInformation( + "Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.", + player.Name, + merchant.Definition.Designation, + soldCount, + boughtCount, + player.Money); + } + } + finally + { + await CloseAction.CloseNpcDialogAsync(player).ConfigureAwait(false); + } + + return true; + } + + /// + /// Gets the potion kinds (item numbers in group 14) whose stack is running low. + /// + private static IEnumerable GetLowPotionKinds(Player player) + { + if (GetPotionCharges(player, 3) < PotionLowThreshold) + { + yield return 3; // Large Healing Potion + } + + if (GetPotionCharges(player, 6) < PotionLowThreshold) + { + yield return 6; // Large Mana Potion + } + } + + private static int GetPotionCharges(Player player, byte potionNumber) + { + return (int)(player.Inventory?.Items + .Where(i => i.Definition?.Group == 14 && i.Definition.Number == potionNumber) + .Sum(i => i.Durability) ?? 0); + } + + /// + /// Sellable junk: plain unequipped gear in the backpack - not potions/ammunition, not jewels and + /// not excellent/ancient pieces (those stay as the bot's "wealth", like a player would keep them). + /// + private static bool IsSellableJunk(Item item) + { + if (item.ItemSlot < InventoryConstants.EquippableSlotsCount + || item.Definition is not { } definition) + { + return false; + } + + if (definition.Group >= 12 || definition.IsAmmunition) + { + return false; // potions, jewels, scrolls, event items etc. + } + + if (Valuables.Contains(new ItemIdentifier(definition.Number, definition.Group))) + { + return false; + } + + var isExcellentOrAncient = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent) + || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0); + return !isExcellentOrAncient; + } +} diff --git a/src/GameLogic/GameMap.cs b/src/GameLogic/GameMap.cs index 159afdcdf..ce7ccf469 100644 --- a/src/GameLogic/GameMap.cs +++ b/src/GameLogic/GameMap.cs @@ -114,6 +114,17 @@ public IList GetAttackablesInRange(Point point, int range) return this._areaOfInterestManager.GetInRange(point, range).OfType().ToList(); } + /// + /// Gets all non-player characters (e.g. merchants) within the specified range of a point. + /// + /// The coordinates. + /// The range. + /// The non-player characters in range of the specified coordinate. + public IList GetNpcsInRange(Point point, int range) + { + return this._areaOfInterestManager.GetInRange(point, range).OfType().ToList(); + } + /// /// Gets all dropped items and money within the specified range of a point. /// From 4fa5f37c3645f59fa3bc9042af3597808ceffbaf Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 13:57:26 +0200 Subject: [PATCH 17/60] bots: level range up to 250 with class evolution at 200 The generated level range grows from 10-80 to 10-250, with a skewed distribution (low and mid levels more common, like a real population pyramid). With the higher levels, the upper maps finally get a resident bot population - validated: bots spread across 14 maps including Lost Tower, Tarkan, Aida, Kanturu, Vulcanus and Karutan, instead of only the four starter towns. At level 200 a bot changes into its second-generation class - Dark Knight to Blade Knight, Dark Wizard to Soul Master, Fairy Elf to Muse Elf, Summoner to Bloody Summoner - the exact assignment the class-change quest performs for a human player; simulating the quest run itself would be invisible to observers. Bots generated beyond 200 are created as the evolved class right away, bots that grow past 200 in play evolve on level-up. Skills, buffs and gear of the new class follow automatically, since all bot progression keys off the current class's qualifications (the stat builds cover the evolved classes too). The Magic Gladiator, Dark Lord and Rage Fighter have no second generation - their next step is the level-400 master evolution, out of bot scope. Shopping keeps a Zen reserve now, so a bot stops buying rather than spend its last coin. --- src/GameLogic/Bots/BotGenerator.cs | 26 ++++++++++++-- src/GameLogic/Bots/BotProgression.cs | 34 +++++++++++++++++-- src/GameLogic/Bots/BotShoppingHandler.cs | 7 +++- .../Bots/BotSkillProgressionPlugIn.cs | 25 ++++++++++++++ 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 5f362454d..d196f167b 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -28,7 +28,20 @@ internal sealed class BotGenerator { private const string LoginPrefix = "bot"; private const int MinLevel = 10; - private const int MaxLevel = 80; + + /// + /// The highest generated level. High enough that the upper maps (Tarkan, Aida, Kanturu, ...) get a + /// resident bot population and that some bots start beyond the class evolution level + /// () - those are created as their second-generation + /// class right away, like a player who did the class quest long ago. + /// + private const int MaxLevel = 250; + + /// + /// Skew of the level distribution: values above 1 make low and mid levels more common than high + /// ones, like a real server's population pyramid (an even spread would feel top-heavy). + /// + private const double LevelSkew = 1.6; private const int StartMoney = 100000; /// Upgrade level (+6) of the starter gear, giving fresh bots a survival buffer until they can warp. @@ -133,7 +146,7 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters for (byte slot = 0; slot < perAccount; slot++) { var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!; - var level = Rand.NextInt(minLevel, maxLevel + 1); + var level = minLevel + (int)((maxLevel - minLevel) * Math.Pow(Rand.NextInt(0, 1001) / 1000.0, LevelSkew)); var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false); this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable); } @@ -234,6 +247,15 @@ private static byte[] CreateDefaultKeyConfiguration() private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable) { + // A character generated beyond the class evolution level was created as its second-generation + // class right away - like a player who completed the class quest long ago. Everything downstream + // (stat weights, skills, gear) keys off the evolved class. + if (level >= BotProgression.ClassEvolutionLevel + && BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass) + { + characterClass = evolvedClass; + } + var character = context.CreateNew(); character.CharacterClass = characterClass; character.Name = name; diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs index e02f1bb83..ad13c2f99 100644 --- a/src/GameLogic/Bots/BotProgression.cs +++ b/src/GameLogic/Bots/BotProgression.cs @@ -16,13 +16,41 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// internal static class BotProgression { + /// + /// The character level at which a bot changes into its second-generation class (e.g. Dark Knight + /// to Blade Knight), the way a player completes the class-change quest. The quest itself boils down + /// to exactly this assignment (see QuestCompletionAction), so bots take the direct route. + /// + public const int ClassEvolutionLevel = 200; + /// /// The character class numbers from the game's data model (CharacterClassNumber lives in the /// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here). /// private const byte FairyElfNumber = 8; + private const byte MuseElfNumber = 10; private const byte DarkLordNumber = 16; + private const byte LordEmperorNumber = 17; private const byte RageFighterNumber = 24; + private const byte FistMasterNumber = 25; + + /// + /// The base classes which evolve into a second-generation class at : + /// Dark Wizard, Dark Knight, Fairy Elf and Summoner. The Magic Gladiator, Dark Lord and Rage Fighter + /// have no second generation - their next class is the level-400 master evolution, out of bot scope. + /// + private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20]; + + /// + /// Gets the class the character evolves into at , or null when the + /// class has no (in-scope) evolution. + /// + public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass) + { + return EvolvableClassNumbers.Contains(characterClass.Number) + ? characterClass.NextGenerationClass + : null; + } /// /// How a bot invests its stat points, per class. Mirrors a sensible human build: everyone puts a @@ -36,9 +64,9 @@ internal static class BotProgression { return characterClass.Number switch { - FairyElfNumber => new[] { (Stats.BaseVitality, 40), (Stats.BaseAgility, 40), (Stats.BaseEnergy, 20) }, - DarkLordNumber => new[] { (Stats.BaseStrength, 35), (Stats.BaseVitality, 30), (Stats.BaseLeadership, 25), (Stats.BaseEnergy, 10) }, - RageFighterNumber => new[] { (Stats.BaseStrength, 45), (Stats.BaseVitality, 35), (Stats.BaseEnergy, 20) }, + FairyElfNumber or MuseElfNumber => new[] { (Stats.BaseVitality, 40), (Stats.BaseAgility, 40), (Stats.BaseEnergy, 20) }, + DarkLordNumber or LordEmperorNumber => new[] { (Stats.BaseStrength, 35), (Stats.BaseVitality, 30), (Stats.BaseLeadership, 25), (Stats.BaseEnergy, 10) }, + RageFighterNumber or FistMasterNumber => new[] { (Stats.BaseStrength, 45), (Stats.BaseVitality, 35), (Stats.BaseEnergy, 20) }, _ => new[] { (GetMainDamageStat(characterClass), 50), (Stats.BaseVitality, 50) }, }; } diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs index 4cd611fcb..49f5b7a28 100644 --- a/src/GameLogic/Bots/BotShoppingHandler.cs +++ b/src/GameLogic/Bots/BotShoppingHandler.cs @@ -36,6 +36,9 @@ internal static class BotShoppingHandler /// Maximum purchases per potion kind per trip. private const int MaxPurchasesPerKind = 20; + /// Zen the bot keeps in reserve - it stops buying rather than spend its last coin. + private const int MinZenReserve = 10000; + private static readonly TalkNpcAction TalkAction = new(); private static readonly SellItemToNpcAction SellAction = new(); private static readonly BuyNpcItemAction BuyAction = new(); @@ -148,7 +151,9 @@ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap continue; } - for (var i = 0; i < MaxPurchasesPerKind && GetPotionCharges(player, potionNumber) < PotionTargetCharges; i++) + for (var i = 0; i < MaxPurchasesPerKind + && GetPotionCharges(player, potionNumber) < PotionTargetCharges + && player.Money > MinZenReserve; i++) { var moneyBefore = player.Money; await BuyAction.BuyItemAsync(player, storeItem.ItemSlot).ConfigureAwait(false); diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs index c38968b62..b0d554684 100644 --- a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -55,6 +55,7 @@ private async Task ProgressAsync(Player player) { try { + this.EvolveClassIfDue(player); await this.SpendStatPointsAsync(player).ConfigureAwait(false); await this.LearnNewSkillsAsync(player).ConfigureAwait(false); } @@ -64,6 +65,30 @@ private async Task ProgressAsync(Player player) } } + /// + /// Changes the bot into its second-generation class (Dark Knight -> Blade Knight etc.) once it + /// reaches - the exact assignment the class-change + /// quest performs for a human player (see QuestCompletionAction); simulating the quest run + /// itself would be invisible to observers anyway. Skills and gear of the new class follow + /// automatically, because all bot progression keys off the current class's qualifications. + /// + private void EvolveClassIfDue(Player player) + { + var character = player.SelectedCharacter!; + if (player.Level < BotProgression.ClassEvolutionLevel + || BotProgression.GetEvolutionTarget(character.CharacterClass!) is not { } evolvedClass) + { + return; + } + + character.CharacterClass = evolvedClass; + player.Logger.LogInformation( + "Bot '{Name}' evolved into {Class} at level {Level}.", + player.Name, + evolvedClass.Name, + player.Level); + } + private async ValueTask SpendStatPointsAsync(Player player) { var character = player.SelectedCharacter!; From 059e0f89fa8fdf9adb1e17177f216e048f168cd4 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 14:40:53 +0200 Subject: [PATCH 18/60] bots: satisfy StyleCop member ordering and documentation rules Mechanical cleanup only - member reordering (constants before fields, public before private, static before instance, nested types last), missing XML parameter docs, using-directive order and blank lines. No logic changes; verified by a sorted-line diff (only doc lines and blank lines added) and a clean build with zero analyzer warnings left in the bot feature files. --- src/GameLogic/Bots/BotEquipmentHandler.cs | 31 ++-- src/GameLogic/Bots/BotFeaturePlugIn.cs | 58 +++---- src/GameLogic/Bots/BotGenerator.cs | 102 ++++++------ src/GameLogic/Bots/BotMuHelperSettings.cs | 1 + src/GameLogic/Bots/BotNavigator.cs | 190 +++++++++++----------- src/GameLogic/Bots/BotProgression.cs | 26 ++- src/GameLogic/Bots/BotShoppingHandler.cs | 5 + src/GameLogic/Offline/CombatHandler.cs | 31 ++-- src/GameLogic/Offline/HealingHandler.cs | 6 +- src/GameLogic/Offline/OfflinePlayer.cs | 84 +++++----- 10 files changed, 277 insertions(+), 257 deletions(-) diff --git a/src/GameLogic/Bots/BotEquipmentHandler.cs b/src/GameLogic/Bots/BotEquipmentHandler.cs index be3d0090a..0206db28d 100644 --- a/src/GameLogic/Bots/BotEquipmentHandler.cs +++ b/src/GameLogic/Bots/BotEquipmentHandler.cs @@ -27,6 +27,8 @@ internal static class BotEquipmentHandler /// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear, /// so the pickup handler only collects items worth carrying. /// + /// The bot player which would wear the item. + /// The dropped item to evaluate. public static bool IsUpgradeFor(Player player, Item item) { if (item.Definition is not { } definition @@ -61,25 +63,12 @@ public static bool IsUpgradeFor(Player player, Item item) return false; } - /// - /// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which - - /// if it is a weapon - matches the class's fighting style (an elf only considers bows, a wizard only - /// staves), so bots don't fill their off-hand with random qualified junk like a Small Axe. - /// - private static bool IsWearableCandidate(ItemDefinition definition, CharacterClass characterClass) - { - const byte lastWeaponGroup = 5; - return definition.ItemSlot is { ItemSlots.Count: > 0 } - && !definition.IsAmmunition - && definition.QualifiedCharacters.Contains(characterClass) - && (definition.Group > lastWeaponGroup || BotProgression.IsPreferredWeaponGroup(characterClass, (byte)definition.Group)); - } - /// /// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece is /// dropped to the ground (a real player would leave the outgrown junk behind too), unless it is /// something valuable (excellent/ancient), which stays in the backpack. /// + /// The bot player whose backpack is scanned. public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player) { if (player.Inventory is not { } inventory @@ -186,6 +175,20 @@ public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player) } } + /// + /// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which - + /// if it is a weapon - matches the class's fighting style (an elf only considers bows, a wizard only + /// staves), so bots don't fill their off-hand with random qualified junk like a Small Axe. + /// + private static bool IsWearableCandidate(ItemDefinition definition, CharacterClass characterClass) + { + const byte lastWeaponGroup = 5; + return definition.ItemSlot is { ItemSlots.Count: > 0 } + && !definition.IsAmmunition + && definition.QualifiedCharacters.Contains(characterClass) + && (definition.Group > lastWeaponGroup || BotProgression.IsPreferredWeaponGroup(characterClass, (byte)definition.Group)); + } + /// /// A rough, monotonic quality score of an equippable item: the definition's drop level tracks the /// gear tier, the item level its upgrades, and excellent/ancient options add their extra worth. diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 4a862a74d..cf95e24ec 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -26,11 +26,22 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus /// private static readonly TimeSpan StartupDelay = TimeSpan.FromSeconds(15); - private readonly BotManager _botManager = new(); - private static readonly TimeSpan MaintenanceInterval = TimeSpan.FromSeconds(60); private static readonly TimeSpan PartyReformInterval = TimeSpan.FromMinutes(60); + /// + /// The typical activity of a player base by local hour (0..1): quietest in the early morning, + /// busiest in the evening. Scales between + /// and 100% of the bot population. + /// + private static readonly double[] ActivityByHour = + [ + 0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45, + 0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50, + ]; + + private readonly BotManager _botManager = new(); + private DateTime _nextRunUtc = DateTime.UtcNow + StartupDelay; private DateTime _nextMaintenanceUtc = DateTime.UtcNow + StartupDelay + StartupDelay; private DateTime _nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; @@ -157,16 +168,22 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } } - /// - /// The typical activity of a player base by local hour (0..1): quietest in the early morning, - /// busiest in the evening. Scales between - /// and 100% of the bot population. - /// - private static readonly double[] ActivityByHour = - [ - 0.30, 0.15, 0.05, 0.00, 0.00, 0.05, 0.10, 0.20, 0.30, 0.35, 0.40, 0.45, - 0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50, - ]; + /// + public void ForceStart() + { + this._nextRunUtc = DateTime.UtcNow; + } + + /// + public object CreateDefaultConfig() + { + return CreateDefaultConfiguration(); + } + + private static BotConfiguration CreateDefaultConfiguration() + { + return new BotConfiguration(); + } /// /// Runs the periodic post-spawn maintenance: the presence rotation (one bot in or out per pass, so @@ -254,23 +271,6 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfigur } } - /// - public void ForceStart() - { - this._nextRunUtc = DateTime.UtcNow; - } - - /// - public object CreateDefaultConfig() - { - return CreateDefaultConfiguration(); - } - - private static BotConfiguration CreateDefaultConfiguration() - { - return new BotConfiguration(); - } - /// /// Persists the current configuration back to its row, so a /// programmatic change (e.g. clearing the reset flag) survives a restart. diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index d196f167b..36dda92db 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -163,30 +163,6 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters return created; } - /// - /// Builds a shuffled queue of character classes with even quotas across , - /// so the generated population is balanced instead of relying on the variance of independent random - /// draws. The order is randomized so accounts do not get a predictable class pattern. - /// - private static Queue BuildBalancedClassQueue(IList classes, int total) - { - var pool = new List(total); - for (var n = 0; n < total; n++) - { - // Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes). - pool.Add(classes[n % classes.Count]); - } - - // Fisher-Yates shuffle so the balanced pool is handed out in random order. - for (var n = pool.Count - 1; n > 0; n--) - { - var j = Rand.NextInt(0, n + 1); - (pool[n], pool[j]) = (pool[j], pool[n]); - } - - return new Queue(pool); - } - /// /// Deletes all bot accounts (and, by cascade, their characters and owned data). /// @@ -245,6 +221,57 @@ private static byte[] CreateDefaultKeyConfiguration() return keyConfiguration; } + /// + /// Builds a shuffled queue of character classes with even quotas across , + /// so the generated population is balanced instead of relying on the variance of independent random + /// draws. The order is randomized so accounts do not get a predictable class pattern. + /// + private static Queue BuildBalancedClassQueue(IList classes, int total) + { + var pool = new List(total); + for (var n = 0; n < total; n++) + { + // Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes). + pool.Add(classes[n % classes.Count]); + } + + // Fisher-Yates shuffle so the balanced pool is handed out in random order. + for (var n = pool.Count - 1; n > 0; n--) + { + var j = Rand.NextInt(0, n + 1); + (pool[n], pool[j]) = (pool[j], pool[n]); + } + + return new Queue(pool); + } + + /// + /// Spends the character's level-up points, so a high-level bot actually has high-level stats. + /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and + /// damage) and die instantly. The split follows the class build in : + /// vitality and the damage stat for everyone, plus the energy/leadership the class's own support + /// skills require - the same split the bot keeps using for points it earns at runtime. + /// + private static void DistributeStatPoints(Character character, CharacterClass characterClass) + { + var points = character.LevelUpPoints; + if (points <= 0) + { + return; + } + + var weights = BotProgression.GetStatWeights(characterClass); + foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) + { + var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat); + if (attribute is not null) + { + attribute.Value += amount; + character.LevelUpPoints -= amount; + } + } + } + private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable) { // A character generated beyond the class evolution level was created as its second-generation @@ -293,33 +320,6 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam account.Characters.Add(character); } - /// - /// Spends the character's level-up points, so a high-level bot actually has high-level stats. - /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and - /// damage) and die instantly. The split follows the class build in : - /// vitality and the damage stat for everyone, plus the energy/leadership the class's own support - /// skills require - the same split the bot keeps using for points it earns at runtime. - /// - private static void DistributeStatPoints(Character character, CharacterClass characterClass) - { - var points = character.LevelUpPoints; - if (points <= 0) - { - return; - } - - var weights = BotProgression.GetStatWeights(characterClass); - foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) - { - var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat); - if (attribute is not null) - { - attribute.Value += amount; - character.LevelUpPoints -= amount; - } - } - } - /// /// Teaches the character the class skills appropriate to its level and stats - attack skills as well /// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index f9c20ea5a..085aacdf0 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -93,6 +93,7 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings // With the class heal skill learned (e.g. elf Heal), the HealingHandler casts it below the threshold // before falling back to potions - the same order a real player follows. + /// public bool AutoHeal => true; diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 55832bf60..2c59d7edb 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -7,12 +7,12 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Threading; using MUnique.OpenMU.DataModel.Entities; -using MUnique.OpenMU.Persistence; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.GameLogic.PlayerActions; using MUnique.OpenMU.Pathfinding; +using MUnique.OpenMU.Persistence; /// /// Drives a bot's high-level navigation. It picks a hunting ground that suits the bot's level from @@ -22,9 +22,6 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// internal sealed class BotNavigator : AsyncDisposable { - private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); - private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); - /// A bot only warps to another map if it offers monsters at least this many levels stronger (avoids hopping for tiny gains). private const int WarpImprovementMargin = 3; @@ -80,15 +77,6 @@ internal sealed class BotNavigator : AsyncDisposable /// private const int PathReuseTolerance = 4; - /// How often the bot checks its backpack for equippable upgrades. - private static readonly TimeSpan EquipCheckInterval = TimeSpan.FromSeconds(8); - - /// How often the bot considers whether it needs a shopping trip. - private static readonly TimeSpan ShoppingCheckInterval = TimeSpan.FromSeconds(30); - - /// Cooldown after a shopping trip before considering the next one. - private static readonly TimeSpan ShoppingCooldown = TimeSpan.FromMinutes(10); - /// How close (tiles) the bot must be to the merchant to trade. private const int MerchantTalkRange = 3; @@ -109,15 +97,27 @@ internal sealed class BotNavigator : AsyncDisposable private const int MaxPointPickAttempts = 25; + /// A party member keeps within this distance (tiles) of its leader; beyond it, it walks back. + private const int FollowDistance = 10; + + private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); + private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); + + /// How often the bot checks its backpack for equippable upgrades. + private static readonly TimeSpan EquipCheckInterval = TimeSpan.FromSeconds(8); + + /// How often the bot considers whether it needs a shopping trip. + private static readonly TimeSpan ShoppingCheckInterval = TimeSpan.FromSeconds(30); + + /// Cooldown after a shopping trip before considering the next one. + private static readonly TimeSpan ShoppingCooldown = TimeSpan.FromMinutes(10); + /// Minimum time between two cross-map warps, so a bot does not bounce between maps. private static readonly TimeSpan WarpCooldown = TimeSpan.FromSeconds(60); /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly). private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20); - /// A party member keeps within this distance (tiles) of its leader; beyond it, it walks back. - private const int FollowDistance = 10; - /// /// If the bot's position has not changed for this long it is considered stuck (a wedged walk, or a /// monster it can't reach). The navigator then forces a fresh destination so the bot gets going again. @@ -205,6 +205,73 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } + /// + /// Determines whether the bot has outgrown the given map: the strongest monster it could safely + /// hunt anywhere is meaningfully above the strongest one this map has to offer. + /// + private static bool IsMapOutgrown(GameMapDefinition mapDefinition, int botLevel) + { + var cap = GetHuntCap(botLevel); + return BestHuntableLevel(mapDefinition, cap) + WarpImprovementMargin < cap; + } + + private static PathFinder CreateTravelPathFinder() + { + return new PathFinder(new FullGridNetwork(true)) + { + Heuristic = new ManhattanTravelHeuristic(), + HeuristicEstimate = TravelHeuristicWeight, + SearchLimit = TravelSearchLimit, + }; + } + + private static ConcurrentBag CreateTravelPathFinderPool() + { + var pool = new ConcurrentBag(); + for (var i = 0; i < TravelPathFinderPoolSize; i++) + { + pool.Add(CreateTravelPathFinder()); + } + + return pool; + } + + /// The highest monster level a bot of the given level should fight - shared with the combat AI. + private static int GetHuntCap(int botLevel) => CombatHandler.GetSafeHuntCap(botLevel); + + /// The strongest monster level on the map which is still at or below the safe cap; 0 if none. + private static int BestHuntableLevel(GameMapDefinition mapDefinition, int cap) + { + var best = 0; + foreach (var area in mapDefinition.MonsterSpawns) + { + if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + { + continue; + } + + var level = GetMonsterLevel(area.MonsterDefinition!); + if (level > 0 && level <= cap && level > best) + { + best = level; + } + } + + return best; + } + + private static int GetMonsterLevel(MonsterDefinition definition) + => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f); + + private static int GroundWeight(MonsterSpawnArea area, Point from) + { + var centerX = (area.X1 + area.X2) / 2; + var centerY = (area.Y1 + area.Y2) / 2; + var distance = Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); + var proximity = Math.Max(1, 300 - distance); + return Math.Max(1, (int)area.Quantity) * proximity; + } + private async Task SafeEvaluateAsync(CancellationToken cancellationToken) { // The timer fires on a fixed interval regardless of whether the previous evaluation finished; @@ -548,16 +615,6 @@ private async ValueTask TryWarpToBetterMapAsync() return true; } - /// - /// Determines whether the bot has outgrown the given map: the strongest monster it could safely - /// hunt anywhere is meaningfully above the strongest one this map has to offer. - /// - private static bool IsMapOutgrown(GameMapDefinition mapDefinition, int botLevel) - { - var cap = GetHuntCap(botLevel); - return BestHuntableLevel(mapDefinition, cap) + WarpImprovementMargin < cap; - } - /// /// Walks the next stretch towards the destination. A full route is resolved with a heuristic search /// (so it goes around walls and out of the town gate) and the first steps are @@ -765,27 +822,6 @@ private async ValueTask EnsurePotionStackAsync(int potionNumber) } } - private static PathFinder CreateTravelPathFinder() - { - return new PathFinder(new FullGridNetwork(true)) - { - Heuristic = new ManhattanTravelHeuristic(), - HeuristicEstimate = TravelHeuristicWeight, - SearchLimit = TravelSearchLimit, - }; - } - - private static ConcurrentBag CreateTravelPathFinderPool() - { - var pool = new ConcurrentBag(); - for (var i = 0; i < TravelPathFinderPoolSize; i++) - { - pool.Add(CreateTravelPathFinder()); - } - - return pool; - } - /// /// Finds the position of the nearest live, attackable monster within that /// the bot can safely fight, so the bot heads straight for a real monster instead of a random tile in a @@ -873,30 +909,6 @@ private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinitio private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); - /// The highest monster level a bot of the given level should fight - shared with the combat AI. - private static int GetHuntCap(int botLevel) => CombatHandler.GetSafeHuntCap(botLevel); - - /// The strongest monster level on the map which is still at or below the safe cap; 0 if none. - private static int BestHuntableLevel(GameMapDefinition mapDefinition, int cap) - { - var best = 0; - foreach (var area in mapDefinition.MonsterSpawns) - { - if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) - { - continue; - } - - var level = GetMonsterLevel(area.MonsterDefinition!); - if (level > 0 && level <= cap && level > best) - { - best = level; - } - } - - return best; - } - /// /// Picks the enterable map (other than the current one) that offers the strongest monsters the bot can /// still safely handle, if it is meaningfully better than the current map, with one of its safezone gates. @@ -985,30 +997,6 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL return this.TryPickWalkablePoint(map, chosen.Area, out ground); } - private static int GetMonsterLevel(MonsterDefinition definition) - => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f); - - private static int GroundWeight(MonsterSpawnArea area, Point from) - { - var centerX = (area.X1 + area.X2) / 2; - var centerY = (area.Y1 + area.Y2) / 2; - var distance = Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); - var proximity = Math.Max(1, 300 - distance); - return Math.Max(1, (int)area.Quantity) * proximity; - } - - /// - /// A Manhattan distance heuristic (the path finder applies its own estimate multiplier), used to turn the - /// otherwise unguided search into A* for long-distance bot travel. - /// - private sealed class ManhattanTravelHeuristic : IHeuristic - { - public int HeuristicEstimateMultiplier { get; set; } - - public int CalculateHeuristicDistance(Point location, Point target) - => this.HeuristicEstimateMultiplier * (Math.Abs(location.X - target.X) + Math.Abs(location.Y - target.Y)); - } - private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, out Point point) { var minX = Math.Min(area.X1, area.X2); @@ -1030,4 +1018,16 @@ private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, out Point point = default; return false; } + + /// + /// A Manhattan distance heuristic (the path finder applies its own estimate multiplier), used to turn the + /// otherwise unguided search into A* for long-distance bot travel. + /// + private sealed class ManhattanTravelHeuristic : IHeuristic + { + public int HeuristicEstimateMultiplier { get; set; } + + public int CalculateHeuristicDistance(Point location, Point target) + => this.HeuristicEstimateMultiplier * (Math.Abs(location.X - target.X) + Math.Abs(location.Y - target.Y)); + } } diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs index ad13c2f99..065220c04 100644 --- a/src/GameLogic/Bots/BotProgression.cs +++ b/src/GameLogic/Bots/BotProgression.cs @@ -41,10 +41,19 @@ internal static class BotProgression /// private static readonly byte[] EvolvableClassNumbers = [0, 4, 8, 20]; + /// + /// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's + /// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the + /// bot would put itself to sleep), and Defense (18), which players get from equipping a shield + /// rather than learning it. + /// + private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222]; + /// /// Gets the class the character evolves into at , or null when the /// class has no (in-scope) evolution. /// + /// The character class. public static CharacterClass? GetEvolutionTarget(CharacterClass characterClass) { return EvolvableClassNumbers.Contains(characterClass.Number) @@ -60,6 +69,7 @@ internal static class BotProgression /// like a real player would. The requirement gate in then unlocks /// those skills exactly when the grown stats reach the game's own thresholds. /// + /// The character class. public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass) { return characterClass.Number switch @@ -75,6 +85,8 @@ internal static class BotProgression /// Splits the given points proportionally to the class's stat weights, returning whole-point /// amounts which sum up to (the remainder goes to the first stat). /// + /// The number of points to split. + /// The stat weights of the class. public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(int points, IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights) { var totalWeight = weights.Sum(w => w.Weight); @@ -97,19 +109,12 @@ internal static class BotProgression yield return (weights[0].Stat, points - assigned); } - /// - /// Skills of the buff type which must never enter a bot's auto-buff rotation: the summoner's - /// enemy debuffs (Sleep/Weakness/Innovation - the offline buff handler casts buffs on SELF, so the - /// bot would put itself to sleep), and Defense (18), which players get from equipping a shield - /// rather than learning it. - /// - private static readonly short[] ExcludedBuffSkillNumbers = [18, 219, 221, 222]; - /// /// Determines whether the skill is one a bot may learn: an actual attack skill, or a self/party /// buff or heal with a magic effect (which the offline buff/heal handlers know how to cast). /// Passive boosts, event skills, enemy debuffs and utility skills are left out. /// + /// The skill to check. public static bool IsBotLearnableSkill(Skill skill) { if (skill.AttackDamage > 0 @@ -132,6 +137,8 @@ or SkillType.AreaSkillExplicitHits /// resolves an attribute's current value; returning null means /// the attribute is unknown in the caller's context, which conservatively fails the requirement. /// + /// The skill whose requirements are checked. + /// Resolves an attribute's current value; null means the attribute is unknown. public static bool MeetsRequirements(Skill skill, Func getAttributeValue) { foreach (var requirement in skill.Requirements) @@ -155,6 +162,7 @@ public static bool MeetsRequirements(Skill skill, Func + /// The "total" attribute to map. public static AttributeDefinition? TotalToBaseStat(AttributeDefinition attribute) { if (attribute == Stats.TotalEnergy) @@ -196,6 +204,8 @@ public static bool MeetsRequirements(Skill skill, Func, so an elf never swaps its bow for a /// random axe it happens to be qualified for (which would also displace its arrows). /// + /// The character class. + /// The item group of the weapon. public static bool IsPreferredWeaponGroup(CharacterClass characterClass, byte itemGroup) { const byte maxMeleeGroup = 3; diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs index 49f5b7a28..975f0ace7 100644 --- a/src/GameLogic/Bots/BotShoppingHandler.cs +++ b/src/GameLogic/Bots/BotShoppingHandler.cs @@ -62,6 +62,7 @@ internal static class BotShoppingHandler /// Determines whether the bot should go shopping: the backpack is filling up with sellable junk, /// or a potion stack is running low (and there is Zen to restock with). /// + /// The bot player. public static bool NeedsShopping(OfflinePlayer player) { if (player.Inventory is not { } inventory) @@ -87,6 +88,7 @@ public static bool NeedsShopping(OfflinePlayer player) /// Finds the position of a merchant NPC on the map (from the map's spawn configuration; merchants /// are stationary), preferring one which sells potions. /// + /// The game map. public static Point? FindMerchantPosition(GameMap map) { MonsterSpawnArea? best = null; @@ -116,6 +118,9 @@ public static bool NeedsShopping(OfflinePlayer player) /// Performs the actual trade with the merchant standing near the given position: opens the dialog, /// sells the junk loot, buys potion refills and closes the dialog again. /// + /// The bot player. + /// The game map. + /// The position of the merchant. /// True, if a merchant was found and the trade ran; false if no merchant is there. public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap map, Point merchantPosition) { diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index d649ca8fa..25b04d25b 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -32,13 +32,13 @@ public sealed class CombatHandler /// After this many consecutive failed approaches the target counts as unreachable. private const int MaxApproachFailures = 3; - /// How long an unreachable target is ignored before it may be considered again. - private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10); - private const short DrainLifeBaseSkillId = 214; private const short DrainLifeStrengthenerSkillId = 458; private const short DrainLifeMasterySkillId = 462; + /// How long an unreachable target is ignored before it may be considered again. + private static readonly TimeSpan UnreachableTargetBlacklistDuration = TimeSpan.FromSeconds(10); + private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new(); private readonly OfflinePlayer _player; @@ -81,14 +81,14 @@ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHa public int SkillCooldownTicks => this._skillCooldownTicks; /// - /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds. + /// Gets the hunting range in tiles. /// - private Point OriginPosition => this._player.HuntingOrigin; + public byte HuntingRange => CalculateHuntingRange(this._config); /// - /// Gets the hunting range in tiles. + /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds. /// - public byte HuntingRange => CalculateHuntingRange(this._config); + private Point OriginPosition => this._player.HuntingOrigin; /// /// Calculates the hunting range in tiles from the specified configuration. @@ -105,6 +105,15 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config) return (byte)Math.Max(DefaultRange, config.HuntingRange); } + /// + /// The strongest monster level a bot of the given level should fight. In MU a monster is far tougher + /// than a character of the same level, so bots target monsters well below their own level to survive + /// while still earning experience. Shared by the combat AI and the bot navigator, so a bot never + /// stops travelling for (or engages) a monster it should not fight. + /// + /// The bot's character level. + public static int GetSafeHuntCap(int botLevel) => Math.Max(MinSafeHuntLevel, (int)(botLevel * SafeMonsterFactor)); + /// /// Decrements the skill cooldown counter by one tick. /// @@ -342,14 +351,6 @@ private bool IsWithinSafeHuntLevel(Monster monster) return monster.Attributes[Stats.Level] <= GetSafeHuntCap(botLevel); } - /// - /// The strongest monster level a bot of the given level should fight. In MU a monster is far tougher - /// than a character of the same level, so bots target monsters well below their own level to survive - /// while still earning experience. Shared by the combat AI and the bot navigator, so a bot never - /// stops travelling for (or engages) a monster it should not fight. - /// - public static int GetSafeHuntCap(int botLevel) => Math.Max(MinSafeHuntLevel, (int)(botLevel * SafeMonsterFactor)); - private async ValueTask ExecutePhysicalAttackAsync(IAttackable target) { await target.AttackByAsync(this._player, null, false).ConfigureAwait(false); diff --git a/src/GameLogic/Offline/HealingHandler.cs b/src/GameLogic/Offline/HealingHandler.cs index 35d4ecf79..0ba8af788 100644 --- a/src/GameLogic/Offline/HealingHandler.cs +++ b/src/GameLogic/Offline/HealingHandler.cs @@ -18,6 +18,9 @@ namespace MUnique.OpenMU.GameLogic.Offline; /// public sealed class HealingHandler { + /// Drink a mana potion once mana falls below this share, so casters can keep casting. + private const int ManaThresholdPercent = 30; + private static readonly ItemConsumeAction ConsumeAction = new(); private static readonly ItemIdentifier[] HealthPotionPriority = @@ -35,9 +38,6 @@ public sealed class HealingHandler ItemConstants.SmallManaPotion, ]; - /// Drink a mana potion once mana falls below this share, so casters can keep casting. - private const int ManaThresholdPercent = 30; - private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 88e2e9adc..d9730094c 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -15,8 +15,13 @@ namespace MUnique.OpenMU.GameLogic.Offline; /// public class OfflinePlayer : Player { + /// How long an attack by a player stays "hot" as a self-defense target. + private static readonly TimeSpan AggressionMemory = TimeSpan.FromSeconds(15); + private OfflinePlayerMuHelper? _intelligence; private Task? _intelligenceDisposeTask; + private Player? _lastAggressor; + private DateTime _lastAggressionUtc; /// /// Initializes a new instance of the class. @@ -57,12 +62,6 @@ public OfflinePlayer(IGameContext gameContext) /// internal System.Collections.Concurrent.ConcurrentQueue> PendingBotActions { get; } = new(); - /// How long an attack by a player stays "hot" as a self-defense target. - private static readonly TimeSpan AggressionMemory = TimeSpan.FromSeconds(15); - - private Player? _lastAggressor; - private DateTime _lastAggressionUtc; - /// /// 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. @@ -83,33 +82,6 @@ internal Player? RecentAggressor } } - /// - /// Registers a player who attacked this bot, so the combat AI can defend itself. - /// - internal void RegisterAggressor(Player aggressor) - { - this._lastAggressor = aggressor; - this._lastAggressionUtc = DateTime.UtcNow; - } - - /// - /// Executes and removes all queued . - /// - 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); - } - } - } - /// /// Initializes the offline player by loading the account fresh from the database. /// @@ -171,6 +143,34 @@ public virtual async ValueTask StopAsync() await this.DisconnectAsync().ConfigureAwait(false); } + /// + /// Registers a player who attacked this bot, so the combat AI can defend itself. + /// + /// The player who attacked this bot. + internal void RegisterAggressor(Player aggressor) + { + this._lastAggressor = aggressor; + this._lastAggressionUtc = DateTime.UtcNow; + } + + /// + /// Executes and removes all queued . + /// + 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); + } + } + } + /// protected override async ValueTask InternalDisconnectAsync() { @@ -208,6 +208,15 @@ protected override async ValueTask DisposeAsyncCore() protected override ICustomPlugInContainer CreateViewPlugInContainer() => new OfflineViewPlugInContainer(this); + /// + /// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation. + /// + 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. @@ -221,13 +230,4 @@ private async ValueTask SetupCharacterAsync(Character character) await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); } - - /// - /// Starts the intelligence which drives this offline player. Overridden by bots to also run navigation. - /// - protected virtual void StartIntelligence() - { - this._intelligence = new OfflinePlayerMuHelper(this); - this._intelligence.Start(); - } } \ No newline at end of file From 3e137fae0cf95a5b56d28a416924a9b764f54b4e Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 17:00:31 +0200 Subject: [PATCH 19/60] Fix bot starter weapon selection picking ammunition instead of a bow The starter weapon for archer classes was picked from item group 4 by lowest drop level. Bolts and arrows share that group with drop level 0, so every generated archer got a bolt stack as its "weapon" and fought with bare fists. Exclude ammunition from the weapon selection (also in the fallback query, which covers the same groups). --- src/GameLogic/Bots/BotGenerator.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 36dda92db..fb09371f5 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -396,11 +396,13 @@ float ClassStat(AttributeDefinition attribute) isPreferredWeapon = d => d.Group <= MaxMeleeGroup; } + // Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every + // archer would get a bolt stack as its "weapon" and end up punching with its fists. var weapon = this._gameContext.Configuration.Items - .Where(d => isPreferredWeapon(d) && d.QualifiedCharacters.Contains(characterClass)) + .Where(d => isPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass)) .MinBy(d => d.DropLevel) ?? this._gameContext.Configuration.Items - .Where(d => d.Group <= StaffGroup && d.QualifiedCharacters.Contains(characterClass)) + .Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass)) .MinBy(d => d.DropLevel); if (weapon is not null) { From a246780fcdcdd95dc899bb9a7c62686b0bf95566 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 18:58:01 +0200 Subject: [PATCH 20/60] Judge bot target safety by real monster stats instead of level The safe-hunt rule capped targets at half the bot's level, but a monster's level says nothing about its strength: the high-end maps field "level ~120" monsters hitting for 1000-2300 base damage (Swamp of Calmness, LaCleon) or with ~100k health behind ~340 defense (Vulcanus tanks), several times what regular maps' monsters of the same level deal. High-level bots in modest gear were sent straight into death loops there. A monster now counts as safe when all three hold, computed from its actual attributes against the bot's own: - its average hit (minus the bot's PvM defense) stays under a fraction of the bot's maximum health, - the bot's attack power exceeds the monster's defense with a margin, - the monster dies within a bounded number of net hits, so a bot never besieges a tank monster for ten minutes until its potions run dry. This also scales naturally with equipment: better looted gear raises the bot's defense and damage and unlocks tougher maps, like for a real player. The map-warp evaluation uses the same verdicts; its improvement margin and cooldown grew into a hysteresis band, because borderline safety verdicts flip with the bot's buffs and made two maps leapfrog each other - the bot ping-ponged between them on every cooldown. Validated at 250 bots: deaths dropped from ~9/min to ~1/min while total experience gain went up, high-level bots settled on maps they can actually farm, no more map ping-pong. --- src/GameLogic/Bots/BotNavigator.cs | 122 +++++++++++------------ src/GameLogic/Offline/CombatHandler.cs | 133 ++++++++++++++++++++++--- 2 files changed, 175 insertions(+), 80 deletions(-) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 2c59d7edb..1bdc4a26c 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -22,8 +22,13 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// internal sealed class BotNavigator : AsyncDisposable { - /// A bot only warps to another map if it offers monsters at least this many levels stronger (avoids hopping for tiny gains). - private const int WarpImprovementMargin = 3; + /// + /// A bot only warps to another map if it offers safe monsters at least this many levels stronger. + /// Sized as a hysteresis band: the safety verdict for a borderline monster flips when the bot's + /// defense changes with its buffs, and a small margin let two maps leapfrog each other on every + /// re-evaluation - the bot ping-ponged between them on warp cooldown. + /// + private const int WarpImprovementMargin = 8; /// /// Below this level a bot never warps: it stays on its class starting map (e.g. elves in Noria, @@ -112,8 +117,8 @@ internal sealed class BotNavigator : AsyncDisposable /// Cooldown after a shopping trip before considering the next one. private static readonly TimeSpan ShoppingCooldown = TimeSpan.FromMinutes(10); - /// Minimum time between two cross-map warps, so a bot does not bounce between maps. - private static readonly TimeSpan WarpCooldown = TimeSpan.FromSeconds(60); + /// Minimum time between two cross-map warps, so a bot does not bounce between maps (a real player does not hop maps every minute either). + private static readonly TimeSpan WarpCooldown = TimeSpan.FromMinutes(3); /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly). private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20); @@ -205,16 +210,6 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } - /// - /// Determines whether the bot has outgrown the given map: the strongest monster it could safely - /// hunt anywhere is meaningfully above the strongest one this map has to offer. - /// - private static bool IsMapOutgrown(GameMapDefinition mapDefinition, int botLevel) - { - var cap = GetHuntCap(botLevel); - return BestHuntableLevel(mapDefinition, cap) + WarpImprovementMargin < cap; - } - private static PathFinder CreateTravelPathFinder() { return new PathFinder(new FullGridNetwork(true)) @@ -236,30 +231,6 @@ private static ConcurrentBag CreateTravelPathFinderPool() return pool; } - /// The highest monster level a bot of the given level should fight - shared with the combat AI. - private static int GetHuntCap(int botLevel) => CombatHandler.GetSafeHuntCap(botLevel); - - /// The strongest monster level on the map which is still at or below the safe cap; 0 if none. - private static int BestHuntableLevel(GameMapDefinition mapDefinition, int cap) - { - var best = 0; - foreach (var area in mapDefinition.MonsterSpawns) - { - if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) - { - continue; - } - - var level = GetMonsterLevel(area.MonsterDefinition!); - if (level > 0 && level <= cap && level > best) - { - best = level; - } - } - - return best; - } - private static int GetMonsterLevel(MonsterDefinition definition) => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f); @@ -341,13 +312,12 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) this._lastMoveUtc = DateTime.UtcNow; } - // Only monsters the bot would actually fight count (the combat AI ignores ones above its safe - // level cap), so a too-strong monster next to the bot neither stops its travel nor suppresses - // the stuck watchdog. - var huntCap = GetHuntCap(this.GetBotLevel()); + // Only monsters the bot would actually fight count (the combat AI ignores ones failing its + // damage-based safety check), so a too-strong monster next to the bot neither stops its travel + // nor suppresses the stuck watchdog. var monstersNearby = map.GetAttackablesInRange(this._player.Position, TravelStopRange) .OfType() - .Count(m => m.IsAlive && !m.IsAtSafezone() && m.Attributes[Stats.Level] <= huntCap); + .Count(m => m.IsAlive && !m.IsAtSafezone() && CombatHandler.IsSafeTarget(this._player, m.Definition)); // The bot counts as stuck when it has been frozen on the spot: quickly when there is nothing to // fight (a wedged walk or blocked path), and after a much longer grace when huntable monsters are @@ -393,12 +363,13 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) } } - // A bot that has OUTGROWN its map warps away with priority - even though trivial monsters are - // still around. Without this, the nearest-monster homing always finds SOMETHING huntable on the - // starter map, the warp logic below is never reached, and high-level bots farm level-5 mobs on - // Lorencia forever (with the experience penalty) instead of moving on to their level's maps. + // A bot whose map is no longer the best it could safely hunt warps away with priority - even + // though trivial monsters are still around. Without this, the nearest-monster homing always + // finds SOMETHING huntable on the starter map, the warp logic below is never reached, and + // high-level bots farm level-5 mobs on Lorencia forever (with the experience penalty) instead + // of moving on to their level's maps. TryWarpToBetterMapAsync itself only fires when another + // map is meaningfully better (margin + cooldown), so a bot on an adequate map stays put. if (leaderToFollow is null - && IsMapOutgrown(map.Definition, this.GetBotLevel()) && await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) { return; @@ -596,7 +567,7 @@ private async ValueTask TryWarpToBetterMapAsync() var botLevel = this.GetBotLevel(); if (botLevel < MinWarpLevel || DateTime.UtcNow - this._lastWarpUtc < WarpCooldown - || !this.TryPickBetterMap(botLevel, out var targetGate, out var targetMap, out var targetLevel)) + || !this.TryPickBetterMap(out var targetGate, out var targetMap, out var targetLevel)) { return false; } @@ -832,7 +803,6 @@ private bool TryFindNearestMonsterGround(GameMap map, out Point ground) { ground = default; var position = this._player.Position; - var cap = GetHuntCap(this.GetBotLevel()); var candidates = new List<(Monster Monster, double Distance)>(); foreach (var attackable in map.GetAttackablesInRange(position, MonsterSeekRadius)) { @@ -843,11 +813,10 @@ private bool TryFindNearestMonsterGround(GameMap map, out Point ground) continue; } - var level = GetMonsterLevel(monster.Definition); - if (level <= 0 || level > cap) + if (!CombatHandler.IsSafeTarget(this._player, monster.Definition)) { - // Above the safe cap (too tough) - leave it and let the warp / area logic relocate the bot - // to a map whose monsters it can handle. + // Too dangerous (judged by its real damage, not its level) - leave it and let the warp / + // area logic relocate the bot to a map whose monsters it can handle. continue; } @@ -909,19 +878,44 @@ private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinitio private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); + /// + /// The level of the strongest monster on the map which this bot can safely fight (judged by the + /// monster's real damage against the bot's defense and health, see ); + /// 0 if the map has none. The level of the safe monsters remains the measure of a map's experience + /// value when comparing maps - it just no longer decides what is SAFE. + /// + private int BestSafeLevel(GameMapDefinition mapDefinition) + { + var best = 0; + foreach (var area in mapDefinition.MonsterSpawns) + { + if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + { + continue; + } + + var level = GetMonsterLevel(area.MonsterDefinition!); + if (level > best && CombatHandler.IsSafeTarget(this._player, area.MonsterDefinition!)) + { + best = level; + } + } + + return best; + } + /// /// Picks the enterable map (other than the current one) that offers the strongest monsters the bot can /// still safely handle, if it is meaningfully better than the current map, with one of its safezone gates. /// - private bool TryPickBetterMap(int botLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) + private bool TryPickBetterMap(out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) { gate = default!; mapDefinition = default!; monsterLevel = 0; - var cap = GetHuntCap(botLevel); var current = this._player.CurrentMap?.Definition; - var threshold = (current is null ? 0 : BestHuntableLevel(current, cap)) + WarpImprovementMargin; + var threshold = (current is null ? 0 : this.BestSafeLevel(current)) + WarpImprovementMargin; foreach (var candidate in this._player.GameContext.Configuration.Maps) { @@ -930,7 +924,7 @@ private bool TryPickBetterMap(int botLevel, out ExitGate gate, out GameMapDefini continue; } - var best = BestHuntableLevel(candidate, cap); + var best = this.BestSafeLevel(candidate); if (best < threshold || candidate.TryGetRequirementError(this._player, out _)) { continue; @@ -949,16 +943,16 @@ private bool TryPickBetterMap(int botLevel, out ExitGate gate, out GameMapDefini } /// - /// Picks a hunting ground point from the map's monster spawn areas, preferring monsters that suit the - /// bot's level: ideally within [level-10, level+5] (full experience, safe); otherwise the closest band - /// below (over-leveled bot) or the lowest band above (under-leveled bot). + /// Picks a hunting ground point from the map's monster spawn areas, preferring the strongest monsters + /// the bot can safely fight (see ) - best experience without + /// being slaughtered. If nothing on the map is safe, it falls back to the weakest monsters available + /// (the bot should warp away anyway). /// private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundLevel) { ground = default; groundLevel = 0; - var cap = GetHuntCap(this.GetBotLevel()); var candidates = map.Definition.MonsterSpawns .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!))) @@ -970,8 +964,8 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL } // Prefer the strongest monsters the bot can safely handle (best experience); if none on this map - // are within the safe cap, fall back to the weakest available (the bot should warp away anyway). - var safe = candidates.Where(x => x.Level <= cap).ToList(); + // pass the safety check, fall back to the weakest available (the bot should warp away anyway). + var safe = candidates.Where(x => CombatHandler.IsSafeTarget(this._player, x.Area.MonsterDefinition!)).ToList(); List<(MonsterSpawnArea Area, int Level)> band; if (safe.Count > 0) { diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 25b04d25b..6b4864ab6 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -4,6 +4,8 @@ namespace MUnique.OpenMU.GameLogic.Offline; +using System.Collections.Concurrent; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.NPC; @@ -20,11 +22,28 @@ public sealed class CombatHandler private const byte DefaultRange = 1; private const byte BowRange = 6; - /// See : fraction of the bot's own level it hunts up to. - private const float SafeMonsterFactor = 0.5f; + /// + /// See : 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. + /// + private const float SafeHitHealthShare = 0.20f; + + /// + /// See : 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. + /// + private const float MinAttackAdvantage = 1.2f; - /// The minimum monster level a bot will hunt, so very low-level bots still find targets. - private const int MinSafeHuntLevel = 3; + /// + /// See : 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. + /// + private const int MaxHitsToKill = 100; private const int ComboFinisherDelayTicks = 3; private const int InterSkillDelayTicks = 1; private const int MinComboSkillCount = 3; @@ -41,6 +60,12 @@ public sealed class CombatHandler private static readonly TargetedSkillDefaultPlugin DefaultPlugin = new(); + /// + /// 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. + /// + private static readonly ConcurrentDictionary MonsterStatsCache = new(); + private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; private readonly MovementHandler _movementHandler; @@ -106,13 +131,50 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config) } /// - /// The strongest monster level a bot of the given level should fight. In MU a monster is far tougher - /// than a character of the same level, so bots target monsters well below their own level to survive - /// while still earning experience. Shared by the combat AI and the bot navigator, so a bot never - /// stops travelling for (or engages) a monster it should not fight. + /// 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 + /// 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 (), + /// so it never besieges a tank monster it can barely scratch, and the monster's level must not + /// exceed the bot's own. + /// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages) + /// a monster it should not fight. /// - /// The bot's character level. - public static int GetSafeHuntCap(int botLevel) => Math.Max(MinSafeHuntLevel, (int)(botLevel * SafeMonsterFactor)); + /// The bot player. + /// The monster definition. + public static bool IsSafeTarget(Player player, MonsterDefinition monster) + { + var (monsterLevel, averageDamage, monsterDefense, monsterHealth) = GetMonsterCombatStats(monster); + if (monsterLevel <= 0 || player.Attributes is not { } attributes) + { + return false; + } + + if (monsterLevel > (int)attributes[Stats.Level]) + { + return false; + } + + var netHit = averageDamage - attributes[Stats.DefensePvm]; + if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth])) + { + return false; + } + + var attackPower = GetAttackPower(player); + if (attackPower <= monsterDefense * MinAttackAdvantage) + { + return false; + } + + return (attackPower - monsterDefense) * MaxHitsToKill >= monsterHealth; + } /// /// Decrements the skill cooldown counter by one tick. @@ -217,6 +279,46 @@ public async ValueTask PerformDrainLifeRecoveryAsync() } } + private static (int Level, float AverageDamage, float Defense, float Health) GetMonsterCombatStats(MonsterDefinition monster) + { + return MonsterStatsCache.GetOrAdd(monster, 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)); + }); + } + + /// + /// 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". + /// + private static float GetAttackPower(Player player) + { + if (player.Attributes is not { } attributes) + { + return 0f; + } + + var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f; + var wizardry = attributes[Stats.WizardryBaseDmg]; + 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(); @@ -335,10 +437,10 @@ private bool IsMonsterAttackable(Monster monster) /// /// With (server-side bots), the combat AI only - /// engages monsters up to the same safe level cap 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 above its level - and dies. Human offline sessions keep the unrestricted - /// behavior, since the player chose their hunting spot deliberately. + /// engages monsters which pass the same 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. /// private bool IsWithinSafeHuntLevel(Monster monster) { @@ -347,8 +449,7 @@ private bool IsWithinSafeHuntLevel(Monster monster) return true; } - var botLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1); - return monster.Attributes[Stats.Level] <= GetSafeHuntCap(botLevel); + return IsSafeTarget(this._player, monster.Definition); } private async ValueTask ExecutePhysicalAttackAsync(IAttackable target) From ab08db124c599743c6c081ba1fa8af4a3a7ad061 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 20:19:02 +0200 Subject: [PATCH 21/60] Fix offline handler tests after bot AI changes The offline MovementHandler and CombatHandler no longer take a fixed origin position - the hunting origin became a mutable property of the OfflinePlayer, so the bot navigator can move it between hunting grounds. The tests now set the player's HuntingOrigin instead of passing it to the constructors. The combat test also accounts for the human-like reaction delay: a fresh target is engaged only after a short randomized pause (the bot first turns towards it), so the walk towards an out-of-range monster starts on a subsequent AI tick. --- .../Offline/CombatHandlerTests.cs | 15 +++++++++++---- .../Offline/MovementHandlerTests.cs | 3 ++- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs index 659a783e8..c1b870dce 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/CombatHandlerTests.cs @@ -50,11 +50,17 @@ public async ValueTask PerformAttackAsync_MovesCloserToTargetAsync() await player.CurrentMap!.AddAsync(monster).ConfigureAwait(false); var config = new MuHelperSettings { HuntingRange = 10 }; - var movementHandler = new MovementHandler(player, config, this._origin); + player.HuntingOrigin = this._origin; + var movementHandler = new MovementHandler(player, config); - var handler = new CombatHandler(player, config, movementHandler, this._origin); + var handler = new CombatHandler(player, config, movementHandler); // Act + // The first call only acquires the target and turns towards it: a fresh target gets a small + // randomized human-like reaction delay (up to 900 ms) before the bot engages, so we call + // again after the delay has certainly elapsed. + await handler.PerformAttackAsync().ConfigureAwait(false); + await Task.Delay(1000).ConfigureAwait(false); await handler.PerformAttackAsync().ConfigureAwait(false); // Assert @@ -93,9 +99,10 @@ public async ValueTask PerformDrainLifeRecoveryAsync_UsesDrainLifeWhenLowHpAsync }; await player.SkillList!.AddLearnedSkillAsync(drainSkill).ConfigureAwait(false); - var movementHandler = new MovementHandler(player, config, this._origin); + player.HuntingOrigin = this._origin; + var movementHandler = new MovementHandler(player, config); - var handler = new CombatHandler(player, config, movementHandler, this._origin); + var handler = new CombatHandler(player, config, movementHandler); // Act await handler.PerformDrainLifeRecoveryAsync().ConfigureAwait(false); diff --git a/tests/MUnique.OpenMU.Tests/Offline/MovementHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/MovementHandlerTests.cs index b05f3b817..e1432e142 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/MovementHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/MovementHandlerTests.cs @@ -44,7 +44,8 @@ public async ValueTask RegroupAsync_DoesNothingWhenWithinRangeAsync() HuntingRange = 5, }; - var handler = new MovementHandler(player, config, origin); + player.HuntingOrigin = origin; + var handler = new MovementHandler(player, config); // Act var result = await handler.RegroupAsync().ConfigureAwait(false); From a5ab05fab2dce0a726e6cb434a184e6a1746481c Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 20:39:54 +0200 Subject: [PATCH 22/60] Address static analysis findings in bot code - Format the bot name suffix with the invariant culture. - Rename ParseProofOfConceptAccounts so it is distinguishable from the ProofOfConceptAccounts property, and note why the computed config values are methods (a get-only property would end up in the serialized plugin configuration). - Name the navigator timer callback parameter properly, so assigning the fire-and-forget task actually targets a discard instead of reusing the parameter. --- src/GameLogic/Bots/BotConfiguration.cs | 4 +++- src/GameLogic/Bots/BotFeaturePlugIn.cs | 2 +- src/GameLogic/Bots/BotNameGenerator.cs | 3 ++- src/GameLogic/Bots/BotNavigator.cs | 2 +- 4 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs index 61a0b7081..22090e853 100644 --- a/src/GameLogic/Bots/BotConfiguration.cs +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -76,6 +76,7 @@ public class BotConfiguration /// Gets the effective, clamped number of characters per account. /// /// A value between 1 and . + /// Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON. public int GetEffectiveCharactersPerAccount() => Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit); @@ -83,7 +84,8 @@ public int GetEffectiveCharactersPerAccount() /// Parses into the distinct, trimmed login names. /// /// The list of login names. - public IReadOnlyList GetProofOfConceptAccounts() + /// Deliberately a method: a get-only property would end up in the serialized plugin configuration JSON. + public IReadOnlyList ParseProofOfConceptAccounts() => this.ProofOfConceptAccounts .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Distinct(StringComparer.OrdinalIgnoreCase) diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index cf95e24ec..44293a8d7 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -141,7 +141,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) } // The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot) accounts. - foreach (var loginName in configuration.GetProofOfConceptAccounts()) + foreach (var loginName in configuration.ParseProofOfConceptAccounts()) { try { diff --git a/src/GameLogic/Bots/BotNameGenerator.cs b/src/GameLogic/Bots/BotNameGenerator.cs index 2cd70f08f..f2bb9621a 100644 --- a/src/GameLogic/Bots/BotNameGenerator.cs +++ b/src/GameLogic/Bots/BotNameGenerator.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using System.Globalization; using System.Threading; using MUnique.OpenMU.Persistence; @@ -78,7 +79,7 @@ private string BuildName(int attempt) // without ever exceeding the 10 character limit. if (attempt > 30) { - var suffix = (attempt % 10).ToString(); + var suffix = (attempt % 10).ToString(CultureInfo.InvariantCulture); name = (name.Length >= 10 ? name[..9] : name) + suffix; } else if (name.Length > 10) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 1bdc4a26c..ca53e219c 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -184,7 +184,7 @@ public BotNavigator(OfflinePlayer player) public void Start() { this._timer ??= new Timer( - _ => _ = this.SafeEvaluateAsync(this._cts.Token), + state => _ = this.SafeEvaluateAsync(this._cts.Token), null, TimeSpan.FromMilliseconds(2000 + Rand.NextInt(0, 1500)), EvaluationInterval); From 8739d83264a611eaa971c9eea902d0a679d93f4f Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 20:54:13 +0200 Subject: [PATCH 23/60] Extend bot self-defense grudge to five minutes Fifteen seconds of aggression memory meant an attacker was forgiven almost immediately after breaking off. The memory still counts from the last received hit and the bot still only engages an aggressor near its position - the longer window just makes a returning attacker get engaged again on sight. --- src/GameLogic/Offline/OfflinePlayer.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index d9730094c..5dca81019 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -15,8 +15,12 @@ namespace MUnique.OpenMU.GameLogic.Offline; /// public class OfflinePlayer : Player { - /// How long an attack by a player stays "hot" as a self-defense target. - private static readonly TimeSpan AggressionMemory = TimeSpan.FromSeconds(15); + /// + /// 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 is engaged again on sight, instead of being forgiven after seconds. + /// + private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5); private OfflinePlayerMuHelper? _intelligence; private Task? _intelligenceDisposeTask; From 43720811f652b43a06240e3b4c502413ba89d440 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 21:32:09 +0200 Subject: [PATCH 24/60] Let bots fight back at players with their strongest skill Self-defense against a player was limited to plain physical attacks, because the area-skill execution path deals its damage exclusively to monsters - a cast at a player would look flashy and hit nothing. Now the bot selects its best affordable skill against a player aggressor like it does against monsters: targeted skills go through the regular targeted path, and the area path additionally strikes the player target itself - and only the target, so a bot's self-defense never splashes uninvolved bystanders. --- src/GameLogic/Offline/CombatHandler.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 6b4864ab6..dc637de83 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -334,14 +334,6 @@ private async ValueTask ExecuteAttackAsync(IAttackable target, SkillEntry? skill { this._player.Rotation = this._player.GetDirectionTo(target); - if (target is Player) - { - // Self-defense against a player: plain attacks only. The area-skill path below deals its - // damage exclusively to monsters, so a skill cast would look flashy but hit nothing. - await this.ExecutePhysicalAttackAsync(target).ConfigureAwait(false); - return; - } - if (skillEntry?.Skill is not { } skill) { await this.ExecutePhysicalAttackAsync(target).ConfigureAwait(false); @@ -487,6 +479,14 @@ await this._player.ForEachWorldObserverAsync( { await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); } + + // A player target (the self-defense aggressor) is hit by the area skill as well - but ONLY + // the target itself. 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. + if (target is Player playerTarget && playerTarget.IsAlive && !playerTarget.IsAtSafezone()) + { + await playerTarget.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); + } } private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill) From 469ab24c4a19770faedda264baaf2c18f3b323d5 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 21:32:09 +0200 Subject: [PATCH 25/60] Make bots avenge their death against player killers A bot which shrugs off being killed and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes back angry. When a human player kills a bot, the bot now marches from its respawn back to the place of its death (same map only) with re-armed aggressor memory, so it engages the killer on sight. One attempt per death, expiring after a few minutes or on arrival. If the same player kills the bot again shortly after, the bot gives up instead of feeding the killer free kills in a death loop: it avoids hunting grounds near the death site for a while and farms somewhere else. A revenge march takes priority over following the party leader, so a killed leader drags its group back with it; afterwards the routine (and party following) resumes. --- src/GameLogic/Bots/BotNavigator.cs | 113 ++++++++++- src/GameLogic/Bots/BotRevengePlugIn.cs | 39 ++++ src/GameLogic/Offline/OfflinePlayer.cs | 188 +++++++++++++++++- .../Offline/OfflinePlayerMuHelper.cs | 3 + 4 files changed, 334 insertions(+), 9 deletions(-) create mode 100644 src/GameLogic/Bots/BotRevengePlugIn.cs diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index ca53e219c..a5f1a4765 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -105,6 +105,13 @@ internal sealed class BotNavigator : AsyncDisposable /// A party member keeps within this distance (tiles) of its leader; beyond it, it walks back. private const int FollowDistance = 10; + /// + /// Hunting grounds closer than this (tiles) to a recent death site are avoided after the same + /// player killed the bot repeatedly (see ), + /// so the bot farms somewhere else instead of walking back into the same lost fight. + /// + private const int DeathSiteAvoidanceRange = 30; + private static readonly TimeSpan EvaluationInterval = TimeSpan.FromSeconds(1); private static readonly TimeSpan EmptyGroundGrace = TimeSpan.FromSeconds(8); @@ -235,12 +242,17 @@ private static int GetMonsterLevel(MonsterDefinition definition) => (int)(definition.Attributes.FirstOrDefault(a => a.AttributeDefinition == Stats.Level)?.Value ?? 0f); private static int GroundWeight(MonsterSpawnArea area, Point from) + { + var proximity = Math.Max(1, 300 - GroundDistance(area, from)); + return Math.Max(1, (int)area.Quantity) * proximity; + } + + /// Manhattan distance between the center of the spawn area and the given point. + private static int GroundDistance(MonsterSpawnArea area, Point from) { var centerX = (area.X1 + area.X2) / 2; var centerY = (area.Y1 + area.Y2) / 2; - var distance = Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); - var proximity = Math.Max(1, 300 - distance); - return Math.Max(1, (int)area.Quantity) * proximity; + return Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); } private async Task SafeEvaluateAsync(CancellationToken cancellationToken) @@ -349,6 +361,16 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // An armed revenge (a player killed this bot; it respawned on the same map) overrides the + // whole normal routine below, including following the party leader: the bot marches back to + // the place of its death. A leader on a revenge march still drags its followers along - the + // posse a wronged player would bring. Once the revenge is spent or times out, the bot falls + // back into the routine (and a follower rejoins its group). + if (await this.TryRevengeMarchAsync(map).ConfigureAwait(false)) + { + return; + } + // Party members follow their leader instead of roaming on their own: to the leader's map when // it warped away, and towards the leader when it moved off. Near the leader they hunt normally // (the party heal/buff handlers and the party experience bonus need the group to stay together). @@ -499,6 +521,54 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) return true; } + /// + /// Marches the bot back to the place of its death while a revenge is armed (see + /// ). The single attempt is spent as soon as + /// the bot arrives or runs into its killer on the way - no extra combat logic is needed here, + /// because the re-armed aggressor memory already makes the combat handler attack the killer on + /// sight. Monsters along the route do not stop the march (self-defense still engages ones right + /// next to the bot); a bot that stopped to farm would never arrive within the revenge time. + /// + /// The current map. + /// True, if the revenge march consumed this tick. + private async ValueTask TryRevengeMarchAsync(GameMap map) + { + if (!this._player.TryGetRevengeDestination(map.Definition, out var deathSite)) + { + return false; + } + + if (this._player.RecentAggressor is { } aggressor + && ReferenceEquals(aggressor.CurrentMap, map) + && this._player.GetDistanceTo(aggressor.Position) <= TravelStopRange) + { + // Ran into the killer before reaching the site - that is who we came for. Stop travelling + // and let the combat handler (which already prioritizes the aggressor) take over. + this._player.ExpireRevenge("the bot caught up with its killer"); + return true; + } + + if (this._player.GetDistanceTo(deathSite) <= HuntingRange) + { + // Arrived. If the killer still stands here, the armed aggressor memory makes the combat + // handler engage; otherwise the bot simply hunts on normally from this spot. + this._player.ExpireRevenge("the bot arrived at the death site"); + return false; + } + + this._destination = deathSite; + this._hasDestination = true; + if (!await this.TravelTowardAsync(map, deathSite).ConfigureAwait(false)) + { + // No walkable route back (e.g. the respawn gate is across a river) - drop the attempt + // instead of re-issuing an impossible walk every tick. + this._player.ExpireRevenge("no route back to the death site"); + return false; + } + + return true; + } + /// /// Gets the party leader this bot should follow, or null if the bot is solo, is the leader itself, /// or the leader is currently not followable (dead, no map). @@ -978,6 +1048,21 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL band = candidates.Where(x => x.Level <= bottom + BandWidth).ToList(); } + // After repeated deaths against the same player, the area around the death site is off the + // menu for a while (see OfflinePlayer.TryGetDeathSiteToAvoid): drop grounds close to it, so + // the bot farms somewhere else instead of walking back into the same lost fight. Only if + // EVERY candidate lies near the death site are they all kept - relocating within the band is + // still better than not picking any ground at all (the point pick below still steers away). + Point? siteToAvoid = this._player.TryGetDeathSiteToAvoid(map.Definition, out var avoidPoint) ? avoidPoint : null; + if (siteToAvoid is { } avoid) + { + var farGrounds = band.Where(x => GroundDistance(x.Area, avoid) > DeathSiteAvoidanceRange).ToList(); + if (farGrounds.Count > 0) + { + band = farGrounds; + } + } + // Weight the choice by spawn quantity and proximity, so the bot prefers nearby, dense grounds // (shorter, safer travel) while keeping some variety. var position = this._player.Position; @@ -988,10 +1073,10 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL } groundLevel = chosen.Level; - return this.TryPickWalkablePoint(map, chosen.Area, out ground); + return this.TryPickWalkablePoint(map, chosen.Area, siteToAvoid, out ground); } - private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, out Point point) + private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, Point? avoidCenter, out Point point) { var minX = Math.Min(area.X1, area.X2); var maxX = Math.Max(area.X1, area.X2); @@ -1002,11 +1087,23 @@ private bool TryPickWalkablePoint(GameMap map, MonsterSpawnArea area, out Point { var x = Rand.NextInt(minX, maxX + 1); var y = Rand.NextInt(minY, maxY + 1); - if (map.Terrain.WalkMap[x, y] && !map.Terrain.SafezoneMap[x, y]) + if (!map.Terrain.WalkMap[x, y] || map.Terrain.SafezoneMap[x, y]) { - point = new Point((byte)x, (byte)y); - return true; + continue; } + + // MU spawn areas can span most of the map, so a random point of an otherwise "far" area + // may still land right at the death site to avoid - re-roll such points. If the area + // offers nothing else, the attempts run out and the pick fails like for any other + // unusable area (the caller then simply retries next tick). + if (avoidCenter is { } avoid + && Math.Abs(x - avoid.X) + Math.Abs(y - avoid.Y) <= DeathSiteAvoidanceRange) + { + continue; + } + + point = new Point((byte)x, (byte)y); + return true; } point = default; diff --git a/src/GameLogic/Bots/BotRevengePlugIn.cs b/src/GameLogic/Bots/BotRevengePlugIn.cs new file mode 100644 index 000000000..b48089345 --- /dev/null +++ b/src/GameLogic/Bots/BotRevengePlugIn.cs @@ -0,0 +1,39 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.PlugIns; + +/// +/// Notes when a (human) player kills a server-side bot, so the bot can march back to the place of +/// its death after respawning and take revenge on the killer. A bot which shrugs off being killed +/// and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes +/// back angry. The return march happens in the ; the counter-attack in +/// the offline , whose re-armed aggressor memory makes the bot engage +/// the killer on sight. +/// +[PlugIn] +[Display(Name = "Bot revenge", Description = "Makes server-side bots return to their death site and take revenge on the player who killed them.")] +[Guid("29B871B0-FBCF-44D4-A677-8A9832AAC193")] +public class BotRevengePlugIn : IAttackableGotKilledPlugIn +{ + /// + public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) + { + if (killed is OfflinePlayer bot + && bot.Account?.IsBot == true + && killer is Player killerPlayer + && killerPlayer is not OfflinePlayer + && !ReferenceEquals(killerPlayer, killed)) + { + bot.RegisterDeathByPlayer(killerPlayer); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 5dca81019..06b156614 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -15,6 +15,12 @@ namespace MUnique.OpenMU.GameLogic.Offline; /// public class OfflinePlayer : Player { + /// + /// 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. + /// + private const int RepeatedKillThreshold = 2; + /// /// 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 @@ -22,11 +28,45 @@ public class OfflinePlayer : Player /// private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5); + /// + /// 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. + /// + private static readonly TimeSpan RevengeDuration = TimeSpan.FromMinutes(3); + + /// + /// How long the bot keeps away from hunting grounds near its death site after the same player + /// killed it repeatedly (see ). + /// + private static readonly TimeSpan DeathSiteAvoidanceDuration = TimeSpan.FromMinutes(10); + + /// + /// How long a death counts toward . A kill by a player the bot + /// has not seen for this long counts as a fresh grudge again, not as a repeated one. + /// + private static readonly TimeSpan DeathCountMemory = TimeSpan.FromMinutes(30); + + /// + /// 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. + /// + private readonly System.Collections.Concurrent.ConcurrentDictionary _deathsByKiller = new(); + private OfflinePlayerMuHelper? _intelligence; private Task? _intelligenceDisposeTask; private Player? _lastAggressor; private DateTime _lastAggressionUtc; + /// + /// The pending (not yet armed, 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. + /// + private volatile RevengeState? _revenge; + + /// See ; immutable and written atomically, like . + private volatile DeathSite? _deathSiteToAvoid; + /// /// Initializes a new instance of the class. /// @@ -157,6 +197,136 @@ internal void RegisterAggressor(Player aggressor) this._lastAggressionUtc = DateTime.UtcNow; } + /// + /// 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 + /// ) with re-armed aggressor memory, so it attacks the killer on + /// sight. A repeated kill by the same player (see ) cancels + /// revenge instead and makes the bot avoid hunting grounds near the death site for a while. + /// + /// The player who killed this bot. + 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); + } + + /// + /// Arms a pending revenge once the bot respawned, called by the + /// 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 attacks the killer on sight, and the revenge gets its time-to-live. + /// + 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); + } + + /// + /// 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). + /// + /// The map the bot is currently on. + /// The place of the bot's death to march back to. + /// true if a revenge is active and was set. + 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; + } + + /// + /// 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. + /// + /// Why the revenge ended, for the log. + 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); + } + } + + /// + /// 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. + /// + /// The map the bot is currently on. + /// The place of the repeated deaths. + /// true if an avoidance is active on the given map and was set. + 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; + } + /// /// Executes and removes all queued . /// @@ -234,4 +404,20 @@ private async ValueTask SetupCharacterAsync(Character character) await this.GameContext.AddPlayerAsync(this).ConfigureAwait(false); await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); } -} \ No newline at end of file + + /// + /// How often (and how recently) a specific player killed this bot. + /// + private sealed record DeathRecord(int Count, DateTime LastDeathUtc); + + /// + /// A revenge for a death by a player's hand: pending while is null + /// (the bot has not respawned yet), armed and running once it is set. + /// + private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDefinition DeathMap, DateTime? ExpiresAtUtc); + + /// + /// A death site the bot avoids when picking hunting grounds, after repeated deaths there. + /// + private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc); +} diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 21cedd2f4..9ae85bdce 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -229,8 +229,11 @@ private async ValueTask HandleDeathAsync() { // 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; } From 44858f484cf641b903bae67d3bb78712d34f49f8 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 22:52:52 +0200 Subject: [PATCH 26/60] Make bots reset-aware on servers with the reset feature When the reset feature plugin is enabled, bots now live the same reset cycle as the human players of that server, entirely driven by its actual ResetConfiguration (via ResetProgressionCalculator - tiers, multipliers, replace/add mode and the reset limit all apply): - A bot reaching the required level resets after a random 1-10 minute grace delay (no mechanical mass reset), keeps hunting until then, and starts its next cycle fresh. The costs are skipped by default - bots don't take part in the economy the costs are balanced for - with a new 'Bots pay reset costs' option to charge them like players. - The reset mirrors the effect of ResetCharacterAction (reset count, level, experience, stats, point pool, move home), but continues in place instead of logging out the connection-less ghost, and re-runs the level-up progression so the granted point pool is re-invested according to the class build right away. - Bot decisions now use the reset-aware effective level (resets x required level + level): target safety, map choice/min warp level and party matching - so a freshly reset veteran isn't sent back to the newbie routine after every reset. - Generated bots get a seeded random reset history (0 up to the configured reset limit) with the points a player of that history would have, so the population includes believable veterans up to max-reset TOP characters right away. Without the reset feature, nothing changes. --- src/GameLogic/Bots/BotConfiguration.cs | 9 + src/GameLogic/Bots/BotGenerator.cs | 76 ++++++- src/GameLogic/Bots/BotManager.cs | 9 +- src/GameLogic/Bots/BotNavigator.cs | 82 ++++++- src/GameLogic/Bots/BotResetHandler.cs | 214 ++++++++++++++++++ src/GameLogic/Offline/CombatHandler.cs | 9 +- .../Offline/BotResetHandlerTests.cs | 110 +++++++++ 7 files changed, 495 insertions(+), 14 deletions(-) create mode 100644 src/GameLogic/Bots/BotResetHandler.cs create mode 100644 tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs index 22090e853..efc50097c 100644 --- a/src/GameLogic/Bots/BotConfiguration.cs +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -64,6 +64,15 @@ public class BotConfiguration [Range(1, MaxCharactersPerAccountLimit)] public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit; + /// + /// Gets or sets a value indicating whether bots pay the configured reset costs (zen, reset items) + /// when they reset their character on a server with the reset feature enabled. Off by default: + /// bots don't take part in the player economy the costs are balanced for, so charging them only + /// stalls their progression (a bot can't farm zen for a billion-zen reset the way players trade). + /// + [Display(Name = "Bots pay reset costs", Description = "If enabled, bots consume the configured zen/item costs for their resets like human players (default: free bot resets).")] + public bool BotsPayResetCosts { get; set; } + /// /// Gets or sets a comma separated list of login names of existing accounts to animate as bots. /// This is the proof-of-concept entry point: every listed account gets a bot driving its diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index fb09371f5..1d559bbe7 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -12,6 +12,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.DataModel.Configuration.Items; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Resets; using MUnique.OpenMU.Persistence; /// @@ -118,6 +119,13 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters var maxLevel = Math.Min(MaxLevel, experienceTable.Length - 1); var minLevel = Math.Clamp(MinLevel, 1, maxLevel); + // On servers with the reset feature the existing population has resets, so freshly generated + // bots get a random reset history too - a visitor should meet believable veterans (even TOP, + // max-reset characters), not a population uniformly starting from zero. Only possible when the + // configuration bounds the resets; unlimited-reset servers keep unseeded bots. + var resetConfiguration = BotResetHandler.GetResetConfiguration(this._gameContext); + var maxSeededResets = resetConfiguration?.ResetLimit is > 0 ? resetConfiguration.ResetLimit.Value : 0; + using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration); var reservedNames = new HashSet(StringComparer.OrdinalIgnoreCase); var created = 0; @@ -147,8 +155,9 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters { var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!; var level = minLevel + (int)((maxLevel - minLevel) * Math.Pow(Rand.NextInt(0, 1001) / 1000.0, LevelSkew)); + var seededResets = maxSeededResets > 0 ? Rand.NextInt(0, maxSeededResets + 1) : 0; var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false); - this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable); + this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, resetConfiguration); } // Save per account so a single failure does not roll back already generated accounts, @@ -272,12 +281,53 @@ private static void DistributeStatPoints(Character character, CharacterClass cha } } - private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable) + /// + /// Calculates the level-up points a character with the given reset history would have available, + /// so a seeded bot invests the same total a player of that history would: the points granted by + /// the resets themselves (looped through , so tiers, + /// multipliers and the replace/add mode of the server's configuration all apply) plus the points + /// earned by leveling. With the level points of the + /// finished cycles were invested and wiped again at each reset, so only the current cycle's count; + /// without it every cycle's investment survived and still counts. + /// + private static int CalculateLevelUpPoints(CharacterClass characterClass, int level, int seededResets, ResetConfiguration? resetConfiguration) + { + var pointsPerLevel = (int)characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue; + if (seededResets <= 0 || resetConfiguration is null) + { + return (level - 1) * pointsPerLevel; + } + + var pointsPerResetOverride = (int)(characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == Stats.PointsPerReset)?.BaseValue ?? 0f); + var resetPoints = 0; + for (var reset = 0; reset < seededResets; reset++) + { + var progression = ResetProgressionCalculator.Calculate(reset, pointsPerResetOverride, resetConfiguration); + resetPoints = resetConfiguration.ReplacePointsPerReset + ? progression.TotalPointsAfterReset + : resetPoints + progression.PointsForReset; + } + + var currentCyclePoints = Math.Max(0, level - resetConfiguration.LevelAfterReset) * pointsPerLevel; + if (resetConfiguration.ResetStats) + { + return resetPoints + currentCyclePoints; + } + + var firstCyclePoints = Math.Max(0, resetConfiguration.RequiredLevel - 1) * pointsPerLevel; + var laterCyclesPoints = (seededResets - 1) * Math.Max(0, resetConfiguration.RequiredLevel - resetConfiguration.LevelAfterReset) * pointsPerLevel; + return resetPoints + firstCyclePoints + laterCyclesPoints + currentCyclePoints; + } + + private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, ResetConfiguration? resetConfiguration) { // A character generated beyond the class evolution level was created as its second-generation // class right away - like a player who completed the class quest long ago. Everything downstream - // (stat weights, skills, gear) keys off the evolved class. - if (level >= BotProgression.ClassEvolutionLevel + // (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset + // history evolved in its first cycle at the latest (it passed the evolution level on the way + // to the reset level), regardless of its current in-cycle level. + var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel; + if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle) && BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass) { characterClass = evolvedClass; @@ -305,13 +355,25 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level); levelAttribute.Value = level; + if (seededResets > 0 + && character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets) is { } resetsAttribute) + { + // Persisted exactly like a real player's resets (a per-character stat attribute), so the + // reset counter, the effective level and the reset limit all see the seeded history. + resetsAttribute.Value = seededResets; + } + character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)]; - character.LevelUpPoints = (int)((level - 1) - * characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue); + character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration); character.InventoryExtensions = BotInventoryExtensions; DistributeStatPoints(character, characterClass); - this.LearnClassSkills(context, character, characterClass, level); + // Skills survive resets, so a seeded veteran knows everything the highest level of its past + // cycles unlocked - level-gated skills are checked against that level, not the current one. + var highestLevelReached = seededResets > 0 && resetConfiguration is not null + ? Math.Max(level, resetConfiguration.RequiredLevel) + : level; + this.LearnClassSkills(context, character, characterClass, highestLevelReached); character.Inventory = context.CreateNew(); character.Inventory.Money = StartMoney; diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index 30964cb1f..2b6b99e5e 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -6,7 +6,6 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; using System.Linq; -using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Offline; /// @@ -174,9 +173,11 @@ public async ValueTask FormPartiesAsync(IGameContext gameContext) const int maxLevelGap = 12; const int partiedSharePercent = 60; + // Matched by the reset-aware effective level (see BotResetHandler.GetEffectiveLevel), so on a + // reset server a freshly reset veteran groups with its peers instead of with real newbies. var candidates = this._bots.Values .Where(b => b.Party is null && b.Attributes is not null) - .OrderBy(b => b.Attributes![Stats.Level]) + .OrderBy(BotResetHandler.GetEffectiveLevel) .ToList(); var index = 0; @@ -189,13 +190,13 @@ public async ValueTask FormPartiesAsync(IGameContext gameContext) } var leader = candidates[index]; - var leaderLevel = (int)leader.Attributes![Stats.Level]; + var leaderLevel = BotResetHandler.GetEffectiveLevel(leader); var targetSize = Rand.NextInt(minPartySize, maxPartySize + 1); var members = new List { leader }; var next = index + 1; while (next < candidates.Count && members.Count < targetSize - && (int)candidates[next].Attributes![Stats.Level] - leaderLevel <= maxLevelGap) + && BotResetHandler.GetEffectiveLevel(candidates[next]) - leaderLevel <= maxLevelGap) { members.Add(candidates[next]); next++; diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index a5f1a4765..e74c44962 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -130,6 +130,17 @@ internal sealed class BotNavigator : AsyncDisposable /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly). private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20); + /// + /// Bounds of the random delay between becoming eligible for a character reset and performing it. + /// A real player finishes the fight, walks to town, maybe trades first - and 250 bots resetting + /// the very second they hit the required level would be an obvious give-away (and a thundering + /// herd of simultaneous re-gearing). The delay is rolled per bot when it becomes eligible. + /// + private static readonly TimeSpan MinResetDelay = TimeSpan.FromMinutes(1); + + /// Upper bound of the random reset delay, see . + private static readonly TimeSpan MaxResetDelay = TimeSpan.FromMinutes(10); + /// /// If the bot's position has not changed for this long it is considered stuck (a wedged walk, or a /// monster it can't reach). The navigator then forces a fresh destination so the bot gets going again. @@ -174,6 +185,7 @@ internal sealed class BotNavigator : AsyncDisposable private Point _travelPathTarget; private Point? _shoppingTarget; private DateTime _nextShoppingCheckUtc = DateTime.MinValue; + private DateTime? _resetDueAtUtc; /// /// Initializes a new instance of the class. @@ -353,6 +365,15 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // On servers with the reset feature, a due character reset outranks the whole routine below: + // the bot has reached the required level, so hunting on only gains it nothing until it resets. + // It keeps fighting normally through the random grace delay, then resets and starts its next + // cycle (see BotResetHandler). + if (await this.TryResetCharacterAsync().ConfigureAwait(false)) + { + return; + } + // A shopping trip (selling junk loot, restocking potions with Zen) runs before everything else, // so it completes even for party members - the follow logic below would otherwise pull them // back to the leader mid-trade. @@ -626,6 +647,59 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) return false; } + /// + /// Performs the bot's character reset once it is due (reset feature enabled, required level + /// reached, reset limit not exhausted). The reset is not executed the moment the bot becomes + /// eligible: a random grace delay (..) is + /// rolled first, during which the bot hunts on normally. After the reset the navigation state is + /// cleared, so the bot starts its next cycle fresh - re-investing its points (queued by the reset + /// handler) and heading for a hunting ground that suits it again. + /// + /// True, if a reset was performed. + private async ValueTask TryResetCharacterAsync() + { + if (BotResetHandler.GetResetConfiguration(this._player.GameContext) is not { } resetConfiguration + || !BotResetHandler.IsResetDue(this._player, resetConfiguration)) + { + this._resetDueAtUtc = null; + return false; + } + + if (this._resetDueAtUtc is not { } dueAt) + { + var delay = MinResetDelay + TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxResetDelay - MinResetDelay).TotalSeconds + 1)); + this._resetDueAtUtc = DateTime.UtcNow + delay; + this._player.Logger.LogDebug( + "Bot {Character} reached the reset level; resetting in {Delay}.", + this._player.Name, + delay); + return false; + } + + if (DateTime.UtcNow < dueAt) + { + return false; + } + + this._resetDueAtUtc = null; + var payCosts = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration?.BotsPayResetCosts == true; + if (!await BotResetHandler.TryResetAsync(this._player, resetConfiguration, payCosts).ConfigureAwait(false)) + { + // E.g. unaffordable costs when BotsPayResetCosts is enabled - the eligibility check above + // re-schedules another attempt on a fresh delay next tick. + return false; + } + + // Start the next cycle fresh: no stale destination or route, and no warp cooldown - a freshly + // reset player heads straight back out, and the point pool (queued by the reset handler into + // the bot's AI tick) is invested before the next evaluation gets this far. + this._hasDestination = false; + this._travelPath = null; + this._emptyGroundSince = null; + this._lastWarpUtc = DateTime.MinValue; + return true; + } + /// /// Warps to the map that offers the strongest monsters this bot can still safely handle, so it /// earns the best experience without being slaughtered. It arrives at the destination safezone @@ -946,7 +1020,13 @@ private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinitio } } - private int GetBotLevel() => (int)(this._player.Attributes?[Stats.Level] ?? 1); + /// + /// The bot's reset-aware effective level (see ): on + /// reset servers a freshly reset bot is nominally level 10 again but keeps the strength of its + /// resets, so the plain level would send it back to the newbie routine (no warping below + /// , newbie maps) after every reset. + /// + private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player); /// /// The level of the strongest monster on the map which this bot can safely fight (judged by the diff --git a/src/GameLogic/Bots/BotResetHandler.cs b/src/GameLogic/Bots/BotResetHandler.cs new file mode 100644 index 000000000..3209f3031 --- /dev/null +++ b/src/GameLogic/Bots/BotResetHandler.cs @@ -0,0 +1,214 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.Resets; + +/// +/// Performs character resets for bots on servers where the is enabled, +/// and provides the reset-aware effective level used by the bot logic. Everything is driven by the +/// server's actual (and ), so +/// bots follow the same reset rules as the human players of that particular server; when the feature +/// is disabled, none of this changes bot behavior at all. +/// +/// +/// The reset itself mirrors the effect of , with two deliberate +/// differences for the connection-less bot ghosts: the costs (zen, reset items) are skipped by default, +/// because bots don't take part in the economy the costs are balanced for (see +/// ), and the +/// step is replaced by continuing in place - a bot has no client to log out. +/// +internal static class BotResetHandler +{ + /// + /// Gets the server's reset configuration, or null when the reset feature is not enabled. + /// + /// The game context. + /// The reset configuration, or null. + public static ResetConfiguration? GetResetConfiguration(IGameContext gameContext) + => gameContext.FeaturePlugIns.GetPlugIn()?.Configuration; + + /// + /// Gets the player's effective level for bot decisions: on a reset server a freshly reset + /// character is level 10 again but fights with the accumulated power of all its resets, so the + /// plain level would misjudge it everywhere (target safety, map choice, party matching). Each + /// reset counts as the level span it took (). + /// Without the reset feature this is simply the character level. + /// + /// The player. + /// The effective level. + public static int GetEffectiveLevel(Player player) + { + var level = (int)(player.Attributes?[Stats.Level] ?? 1); + if (GetResetConfiguration(player.GameContext) is not { } configuration) + { + return level; + } + + var resets = (int)(player.Attributes?[Stats.Resets] ?? 0f); + return (resets * Math.Max(0, configuration.RequiredLevel)) + level; + } + + /// + /// Determines whether the bot is currently eligible for a reset: the reset feature is enabled, + /// the required level is reached and the reset limit (if any) is not yet exhausted. + /// + /// The bot player. + /// The reset configuration. + /// True, if the bot can reset now. + public static bool IsResetDue(Player player, ResetConfiguration configuration) + { + if (player.Attributes is not { } attributes) + { + return false; + } + + if (player.Level < configuration.RequiredLevel) + { + return false; + } + + var nextResetCount = (int)attributes[Stats.Resets] + 1; + return configuration.ResetLimit is not > 0 || nextResetCount <= configuration.ResetLimit; + } + + /// + /// Resets the bot character, mirroring the effect of : the reset + /// count goes up, the level drops to , the stats and + /// level-up points follow the server's configuration, and with + /// the bot warps back to its class home town. Afterwards the regular level-up progression is fired, + /// so the bot immediately re-invests the granted point pool according to its class build - until that + /// runs, the damage-based target safety keeps the temporarily weak bot away from monsters it can no + /// longer handle. + /// + /// The bot player. + /// The reset configuration. + /// Whether the configured costs (zen, reset items) are consumed like for a human player. + /// True, if the reset was performed. + public static async ValueTask TryResetAsync(OfflinePlayer player, ResetConfiguration configuration, bool payCosts) + { + if (player.Attributes is not { } attributes + || player.SelectedCharacter is not { CharacterClass: not null } character + || !IsResetDue(player, configuration)) + { + return false; + } + + var resetProgression = ResetProgressionCalculator.Calculate( + (int)attributes[Stats.Resets], + (int)attributes[Stats.PointsPerReset], + configuration); + + if (payCosts && !await TryConsumeCostsAsync(player, configuration, resetProgression).ConfigureAwait(false)) + { + return false; + } + + attributes[Stats.Resets] = resetProgression.NextResetCount; + attributes[Stats.Level] = configuration.LevelAfterReset; + character.Experience = 0; + + if (configuration.ResetStats) + { + character.CharacterClass!.StatAttributes + .Where(s => s.IncreasableByPlayer) + .ForEach(s => attributes[s.Attribute] = s.BaseValue); + } + + if (configuration.ReplacePointsPerReset) + { + character.LevelUpPoints = resetProgression.TotalPointsAfterReset; + } + else + { + character.LevelUpPoints += resetProgression.PointsForReset; + } + + player.Logger.LogInformation( + "Bot '{Name}' performed reset {ResetCount} and got {Points} points to invest.", + player.Name, + resetProgression.NextResetCount, + character.LevelUpPoints); + + if (configuration.MoveHome) + { + await MoveHomeAsync(player).ConfigureAwait(false); + } + + // No LogOut for the connection-less ghost - instead re-run the level-up progression, which + // invests the whole granted point pool and re-checks the learnable skills (queued into the + // bot's AI tick by BotSkillProgressionPlugIn, exactly like for an earned level-up). + player.GameContext.PlugInManager.GetPlugInPoint()?.CharacterLeveledUp(player); + + try + { + // Persist right away instead of waiting for the periodic save - losing a whole performed + // reset to a crash within that window would hurt far more than ordinary hunting progress. + await player.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its reset; the periodic save will retry.", player.Name); + } + + return true; + } + + /// + /// Consumes the configured reset costs like does for a human + /// player: the required zen and the required amount of the configured reset item. Only used when + /// is enabled. + /// + private static async ValueTask TryConsumeCostsAsync(OfflinePlayer player, ResetConfiguration configuration, ResetProgression resetProgression) + { + IList requiredItems = []; + if (resetProgression.RequiredItemAmount > 0 && configuration.RequiredResetItem is { } requiredDefinition) + { + requiredItems = player.Inventory?.Items + .Where(item => item.Definition is { } definition + && definition.Group == requiredDefinition.Group + && definition.Number == requiredDefinition.Number) + .Take(resetProgression.RequiredItemAmount) + .ToList() ?? []; + if (requiredItems.Count < resetProgression.RequiredItemAmount) + { + return false; + } + } + + if (player.Money < resetProgression.RequiredZen + || (resetProgression.RequiredZen > 0 && !player.TryRemoveMoney(resetProgression.RequiredZen))) + { + return false; + } + + foreach (var item in requiredItems) + { + await player.DestroyInventoryItemAsync(item).ConfigureAwait(false); + } + + return true; + } + + /// + /// Warps the bot to a spawn gate of its class home map, the live-ghost equivalent of the + /// position rewrite performs before logging a player out. + /// + private static async ValueTask MoveHomeAsync(OfflinePlayer player) + { + var homeMapDefinition = player.SelectedCharacter?.CharacterClass?.HomeMap; + if (homeMapDefinition is null + || await player.GameContext.GetMapAsync((ushort)homeMapDefinition.Number).ConfigureAwait(false) is not { } homeMap + || homeMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is not { } spawnGate) + { + return; + } + + await player.WarpToAsync(spawnGate).ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index dc637de83..930b63933 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -7,6 +7,7 @@ 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; @@ -142,7 +143,8 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config) /// 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 (), /// so it never besieges a tank monster it can barely scratch, and the monster's level must not - /// exceed the bot's own. + /// exceed the bot's own (on reset servers: its reset-aware effective level, see + /// ). /// Shared by the combat AI and the bot navigator, so a bot never stops travelling for (or engages) /// a monster it should not fight. /// @@ -156,7 +158,10 @@ public static bool IsSafeTarget(Player player, MonsterDefinition monster) return false; } - if (monsterLevel > (int)attributes[Stats.Level]) + // 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; } diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs new file mode 100644 index 000000000..47eea5cdb --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offline/BotResetHandlerTests.cs @@ -0,0 +1,110 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Offline; + +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Resets; + +/// +/// Tests for . +/// +[TestFixture] +public class BotResetHandlerTests +{ + private IGameContext _gameContext = null!; + + /// + /// Sets up a fresh game context before each test. + /// + [SetUp] + public void SetUp() + { + this._gameContext = GameContextTestHelper.CreateGameContext(); + } + + /// + /// Without the reset feature the effective level is simply the character level. + /// + [Test] + public async ValueTask EffectiveLevelWithoutResetFeatureIsPlainLevelAsync() + { + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); + player.Attributes![Stats.Level] = 123; + + Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo(123)); + } + + /// + /// With the reset feature every reset counts as the configured level span. Uses the same plain + /// test context as , where the added feature plugin is the + /// effective one (the offline helper's context discovers the real, disabled-by-default plugin). + /// + [Test] + public async ValueTask EffectiveLevelCountsResetsAsLevelSpansAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400 } }, true); + player.Attributes![Stats.Level] = 50; + player.Attributes[Stats.Resets] = 3; + + Assert.That(BotResetHandler.GetEffectiveLevel(player), Is.EqualTo((3 * 400) + 50)); + } + + /// + /// A due reset raises the reset count, drops the level and grants the configured points, without + /// consuming any costs when the bot doesn't pay them. + /// + [Test] + public async ValueTask TryResetPerformsResetAndSkipsCostsAsync() + { + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); + var configuration = new ResetConfiguration + { + RequiredLevel = 400, + LevelAfterReset = 10, + RequiredMoney = 500, + MultiplyRequiredMoneyByResetCount = false, + PointsPerReset = 1000, + MultiplyPointsByResetCount = false, + ReplacePointsPerReset = true, + ResetStats = false, + MoveHome = false, + LogOut = false, + }; + this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); + player.Attributes![Stats.Level] = 400; + player.Money = 100; // less than the required zen - must not matter for a non-paying bot + + var performed = await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false); + + Assert.That(performed, Is.True); + Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(1)); + Assert.That((int)player.Attributes[Stats.Level], Is.EqualTo(10)); + Assert.That(player.SelectedCharacter!.LevelUpPoints, Is.EqualTo(1000)); + Assert.That(player.SelectedCharacter.Experience, Is.EqualTo(0)); + Assert.That(player.Money, Is.EqualTo(100)); + } + + /// + /// A reset is not performed below the required level or beyond the reset limit. + /// + [Test] + public async ValueTask TryResetRespectsLevelAndLimitAsync() + { + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); + var configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = 2, MoveHome = false, LogOut = false }; + this._gameContext.FeaturePlugIns.AddPlugIn(new ResetFeaturePlugIn { Configuration = configuration }, true); + + player.Attributes![Stats.Level] = 399; + Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False); + + player.Attributes[Stats.Level] = 400; + player.Attributes[Stats.Resets] = 2; + Assert.That(await BotResetHandler.TryResetAsync(player, configuration, payCosts: false).ConfigureAwait(false), Is.False); + Assert.That((int)player.Attributes[Stats.Resets], Is.EqualTo(2)); + } +} From c9098f074b80cdba535c3eba88b9f65aa4e7bef1 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 8 Jul 2026 23:36:34 +0200 Subject: [PATCH 27/60] Gate bot map access by the warp list like for players Bots picked hunting maps only by hunting safety, so with the reset-aware effective level a freshly reset veteran could end up on maps a player of its plain character level cannot even warp to - and got slaughtered there by aggressive monsters it never chose to attack. Now a bot may only warp to maps which have an entry in the server's warp list whose (class-reduced) level requirement is met by its PLAIN level, exactly like the in-game warp window - and it arrives at that entry's gate. The minimum bot warp level also uses the plain level again. A map without a single safe monster counts as hostile territory: escaping it bypasses the minimum warp level, so stranded veterans move themselves back to legal maps. The effective level remains in place where it reflects real strength: target safety and party matching. --- src/GameLogic/Bots/BotNavigator.cs | 106 +++++++++++++++++++++++++---- 1 file changed, 93 insertions(+), 13 deletions(-) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index e74c44962..5a71bc486 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -5,6 +5,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; using System.Threading; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.Attributes; @@ -709,9 +710,31 @@ private async ValueTask TryResetCharacterAsync() private async ValueTask TryWarpToBetterMapAsync() { var botLevel = this.GetBotLevel(); - if (botLevel < MinWarpLevel - || DateTime.UtcNow - this._lastWarpUtc < WarpCooldown - || !this.TryPickBetterMap(out var targetGate, out var targetMap, out var targetLevel)) + var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1); + + // Two situations force the bot OFF its current map, bypassing the usual minimum warp level: + // a map without a single safe monster is hostile territory (aggressive monsters kill the bot + // while it cannot fight back), and a map the bot could not legally warp to with its plain + // level is off-limits regardless of its strength - a real character of that level cannot be + // there, so neither may the bot (e.g. a veteran stranded on a high map after its reset). + var currentMap = this._player.CurrentMap?.Definition; + var mapIsHostile = currentMap is not null && this.BestSafeLevel(currentMap) == 0; + var mapIsIllegal = currentMap is not null + && !this.TryGetLegalWarpGate(currentMap, out _) + && currentMap.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number; + var mustEscape = mapIsHostile || mapIsIllegal; + + if ((plainLevel < MinWarpLevel && !mustEscape) + || DateTime.UtcNow - this._lastWarpUtc < WarpCooldown) + { + return false; + } + + // When escaping, any legal map with something safe to hunt beats staying - and with no legal + // warp target at all (a freshly reset veteran may be below every warp requirement), the bot + // retreats to its class home town, like a player using a town scroll. + if (!this.TryPickBetterMap(mustEscape, out var targetGate, out var targetMap, out var targetLevel) + && !(mustEscape && this.TryGetHomeEscapeGate(out targetGate, out targetMap, out targetLevel))) { return false; } @@ -1021,13 +1044,68 @@ private async ValueTask TryPersistCurrentMapAsync(GameMapDefinition mapDefinitio } /// - /// The bot's reset-aware effective level (see ): on - /// reset servers a freshly reset bot is nominally level 10 again but keeps the strength of its - /// resets, so the plain level would send it back to the newbie routine (no warping below - /// , newbie maps) after every reset. + /// The bot's reset-aware effective level (see ), + /// used for hunting decisions (which monsters pay off). Map ACCESS is deliberately not decided + /// by this - the game gates warps by the plain character level (see ), + /// and the bots must obey the same rule. /// private int GetBotLevel() => BotResetHandler.GetEffectiveLevel(this._player); + /// + /// Gets the gate through which the bot may legally warp to the given map, exactly like a player + /// using the in-game warp window: the map must have an entry in the server's warp list whose level + /// requirement (class-reduced, see ) + /// is met by the bot's PLAIN character level. The reset-aware effective level intentionally plays + /// no role here - a freshly reset veteran cannot warp to high maps either, no matter its strength. + /// + /// The target map. + /// The warp target gate, if a legal entry exists. + /// True, if the bot may warp to the map. + private bool TryGetLegalWarpGate(GameMapDefinition mapDefinition, [MaybeNullWhen(false)] out ExitGate gate) + { + gate = null; + if (this._player.SelectedCharacter is not { } character) + { + return false; + } + + var plainLevel = (int)(this._player.Attributes?[Stats.Level] ?? 1); + gate = this._player.GameContext.Configuration.WarpList + .Where(w => w.Gate?.Map?.Number == mapDefinition.Number + && character.GetEffectiveMoveLevelRequirement(w.LevelRequirement) <= plainLevel) + .Select(w => w.Gate!) + .FirstOrDefault(); + return gate is not null; + } + + /// + /// Gets the escape gate to the bot's class home town, for when it must leave its current map but is + /// below every warp requirement (e.g. a freshly reset veteran) - the equivalent of a town scroll. + /// + /// The home map safezone gate. + /// The home map. + /// The level of the strongest safe monster there (informational). + /// True, if the bot has a home map it is not already on. + private bool TryGetHomeEscapeGate([MaybeNullWhen(false)] out ExitGate gate, [MaybeNullWhen(false)] out GameMapDefinition mapDefinition, out int monsterLevel) + { + gate = null; + mapDefinition = null; + monsterLevel = 0; + + var homeMap = this._player.SelectedCharacter?.CharacterClass?.HomeMap; + if (homeMap is null + || homeMap.Number == this._player.CurrentMap?.Definition.Number + || homeMap.GetSafezoneGate() is not { } safezoneGate) + { + return false; + } + + gate = safezoneGate; + mapDefinition = homeMap; + monsterLevel = this.BestSafeLevel(homeMap); + return true; + } + /// /// The level of the strongest monster on the map which this bot can safely fight (judged by the /// monster's real damage against the bot's defense and health, see ); @@ -1055,17 +1133,19 @@ private int BestSafeLevel(GameMapDefinition mapDefinition) } /// - /// Picks the enterable map (other than the current one) that offers the strongest monsters the bot can - /// still safely handle, if it is meaningfully better than the current map, with one of its safezone gates. + /// Picks the legally warpable map (other than the current one) that offers the strongest monsters the + /// bot can still safely handle, if it is meaningfully better than the current map, with its warp gate. + /// In escape mode (fleeing a hostile or illegal map) any legal map with something safe to hunt counts, + /// no matter how it compares to the current one. /// - private bool TryPickBetterMap(out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) + private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) { gate = default!; mapDefinition = default!; monsterLevel = 0; var current = this._player.CurrentMap?.Definition; - var threshold = (current is null ? 0 : this.BestSafeLevel(current)) + WarpImprovementMargin; + var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current)) + WarpImprovementMargin; foreach (var candidate in this._player.GameContext.Configuration.Maps) { @@ -1080,9 +1160,9 @@ private bool TryPickBetterMap(out ExitGate gate, out GameMapDefinition mapDefini continue; } - if (candidate.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } spawnGate) + if (this.TryGetLegalWarpGate(candidate, out var warpGate)) { - gate = spawnGate; + gate = warpGate; mapDefinition = candidate; monsterLevel = best; threshold = best + 1; // keep only a strictly-stronger map after this one From 97f88c8fb8e79a12dffe39eead497610ac2aed2c Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 01:07:46 +0200 Subject: [PATCH 28/60] Model bot stat builds on real player metas Bots now invest their stat points like the human players of their server type, in one of two profiles selected automatically by whether the reset feature is enabled: - Reset meta, modeled on the endgame characters of a reset server: everything flows into the class's combat stats (shield and agility-based defense do the tanking there), while vitality only receives a token share until a per-bot target of 100-500 points - rolled deterministically from the character name - is reached. - Classic meta for servers without resets, following the established community builds where point pools are small and vitality is a plain percentage of the build. Classes with two established builds (knight agility/PK, gladiator warrior/mage, elf archer/supporter) roll their variant per bot from the character name, so the population is diverse but every bot keeps its build across sessions and re-invests it consistently after each reset. The point split now also respects each stat's configured maximum value, like the stat-increase action does for players on servers with stat caps: a full stat drops out of the split and its share flows into the rest of the build; when everything is capped, the points stay unspent like for a maxed-out human character. --- src/GameLogic/Bots/BotGenerator.cs | 41 +++- src/GameLogic/Bots/BotProgression.cs | 197 ++++++++++++++++-- .../Bots/BotSkillProgressionPlugIn.cs | 31 ++- src/GameLogic/Offline/CombatHandler.cs | 4 +- .../Offline/BotProgressionTests.cs | 77 +++++++ 5 files changed, 318 insertions(+), 32 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 1d559bbe7..ab7cb85c1 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -257,11 +257,12 @@ private static Queue BuildBalancedClassQueue(IList /// Spends the character's level-up points, so a high-level bot actually has high-level stats. /// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and - /// damage) and die instantly. The split follows the class build in : - /// vitality and the damage stat for everyone, plus the energy/leadership the class's own support - /// skills require - the same split the bot keeps using for points it earns at runtime. + /// damage) and die instantly. The split follows the class build in + /// for the server's meta profile (reset vs classic) - the same split the bot keeps using for + /// points it earns at runtime - and respects each stat's configured maximum (fun servers) as + /// well as the bot's personal vitality target on reset-meta servers. /// - private static void DistributeStatPoints(Character character, CharacterClass characterClass) + private static void DistributeStatPoints(Character character, CharacterClass characterClass, bool resetMeta) { var points = character.LevelUpPoints; if (points <= 0) @@ -269,8 +270,34 @@ private static void DistributeStatPoints(Character character, CharacterClass cha return; } - var weights = BotProgression.GetStatWeights(characterClass); - foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) + var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta); + var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null; + + long CapacityOf(AttributeDefinition stat) + { + var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat); + if (attribute is null) + { + return 0; + } + + var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat); + var capacity = long.MaxValue; + if (classBase?.Attribute?.MaximumValue is { } maximumValue) + { + capacity = (long)maximumValue - (long)attribute.Value; + } + + if (vitalityTarget is { } target && stat == Stats.BaseVitality) + { + var invested = (long)attribute.Value - (long)(classBase?.BaseValue ?? 0f); + capacity = Math.Min(capacity, target - invested); + } + + return capacity; + } + + foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf)) { var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat); if (attribute is not null) @@ -366,7 +393,7 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)]; character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration); character.InventoryExtensions = BotInventoryExtensions; - DistributeStatPoints(character, characterClass); + DistributeStatPoints(character, characterClass, resetConfiguration is not null); // Skills survive resets, so a seeded veteran knows everything the highest level of its past // cycles unlocked - level-gated skills are checked against that level, not the current one. diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs index 065220c04..de75b8668 100644 --- a/src/GameLogic/Bots/BotProgression.cs +++ b/src/GameLogic/Bots/BotProgression.cs @@ -27,13 +27,31 @@ internal static class BotProgression /// The character class numbers from the game's data model (CharacterClassNumber lives in the /// initialization assembly which GameLogic does not reference, so the relevant values are mirrored here). /// + private const byte DarkWizardNumber = 0; + private const byte SoulMasterNumber = 2; + private const byte GrandMasterNumber = 3; + private const byte DarkKnightNumber = 4; + private const byte BladeKnightNumber = 6; + private const byte BladeMasterNumber = 7; private const byte FairyElfNumber = 8; private const byte MuseElfNumber = 10; + private const byte HighElfNumber = 11; + private const byte MagicGladiatorNumber = 12; + private const byte DuelMasterNumber = 13; private const byte DarkLordNumber = 16; private const byte LordEmperorNumber = 17; + private const byte SummonerNumber = 20; + private const byte BloodySummonerNumber = 22; + private const byte DimensionMasterNumber = 23; private const byte RageFighterNumber = 24; private const byte FistMasterNumber = 25; + /// + /// The share of each invested batch that goes into vitality on reset-meta servers, until the bot's + /// personal is reached (out of a nominal weight total of ~100). + /// + private const int ResetMetaVitalityWeight = 5; + /// /// The base classes which evolve into a second-generation class at : /// Dark Wizard, Dark Knight, Fairy Elf and Summoner. The Magic Gladiator, Dark Lord and Rage Fighter @@ -62,51 +80,186 @@ internal static class BotProgression } /// - /// How a bot invests its stat points, per class. Mirrors a sensible human build: everyone puts a - /// large share into vitality (survival) and their damage stat; classes whose class skills have - /// stat requirements additionally invest what those skills need - elves keep enough energy for - /// their heal/defense/damage orbs, rage fighters for their buffs, dark lords raise leadership - /// like a real player would. The requirement gate in then unlocks - /// those skills exactly when the grown stats reach the game's own thresholds. + /// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen + /// by the server type (see at the call sites): + /// + /// Reset meta (reset feature enabled) - modeled on the actual endgame characters of a + /// reset server: everything goes into the class's combat stats (shield and agility-based defense do + /// the tanking there), vitality only receives a token share until the bot's personal + /// is reached (enforce via capacities). + /// Classic meta (no reset feature) - the builds of community stat guides for classic + /// servers, where point pools are small and vitality is a plain percentage of the build. + /// + /// Where a class has two established builds (knight agility/PK, gladiator warrior/mage, elf + /// archer/supporter), each bot picks one deterministically from its character name, so the + /// population is diverse but every bot keeps the same build across sessions and re-invests + /// consistently after each reset. The first entry is always the build's primary stat - it absorbs + /// rounding remainders and overflow from capped stats. /// /// The character class. - public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass) + /// The character name; decides the build variant for two-build classes. + /// Whether the reset-server meta profile applies. + public static IReadOnlyList<(AttributeDefinition Stat, int Weight)> GetStatWeights(CharacterClass characterClass, string characterName, bool resetMeta) { + // Stable across processes (string.GetHashCode is randomized per run, which would re-spec + // the bot on every server restart). + var variant = characterName.Aggregate(0, (acc, c) => acc + c) % 2; + var vit = Stats.BaseVitality; + var str = Stats.BaseStrength; + var agi = Stats.BaseAgility; + var ene = Stats.BaseEnergy; + var cmd = Stats.BaseLeadership; + + if (resetMeta) + { + const int v = ResetMetaVitalityWeight; + return characterClass.Number switch + { + DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0 + ? new[] { (str, 59), (agi, 39), (ene, 2), (vit, v) } + : new[] { (str, 52), (agi, 35), (ene, 13), (vit, v) }, + DarkWizardNumber or SoulMasterNumber or GrandMasterNumber => + new[] { (ene, 49), (agi, 47), (str, 4), (vit, v) }, + FairyElfNumber or MuseElfNumber or HighElfNumber => + new[] { (agi, 67), (ene, 27), (str, 6), (vit, v) }, + MagicGladiatorNumber or DuelMasterNumber => variant == 0 + ? new[] { (str, 57), (agi, 26), (ene, 17), (vit, v) } + : new[] { (ene, 66), (agi, 18), (str, 16), (vit, v) }, + DarkLordNumber or LordEmperorNumber => + new[] { (str, 54), (cmd, 23), (agi, 18), (ene, 5), (vit, v) }, + SummonerNumber or BloodySummonerNumber or DimensionMasterNumber => + new[] { (ene, 70), (agi, 18), (str, 12), (vit, v) }, + RageFighterNumber or FistMasterNumber => + new[] { (str, 64), (agi, 18), (ene, 18), (vit, v) }, + _ when GetMainDamageStat(characterClass) == str => + new[] { (str, 60), (agi, 35), (vit, v) }, + _ => new[] { (GetMainDamageStat(characterClass), 65), (agi, 30), (vit, v) }, + }; + } + return characterClass.Number switch { - FairyElfNumber or MuseElfNumber => new[] { (Stats.BaseVitality, 40), (Stats.BaseAgility, 40), (Stats.BaseEnergy, 20) }, - DarkLordNumber or LordEmperorNumber => new[] { (Stats.BaseStrength, 35), (Stats.BaseVitality, 30), (Stats.BaseLeadership, 25), (Stats.BaseEnergy, 10) }, - RageFighterNumber or FistMasterNumber => new[] { (Stats.BaseStrength, 45), (Stats.BaseVitality, 35), (Stats.BaseEnergy, 20) }, - _ => new[] { (GetMainDamageStat(characterClass), 50), (Stats.BaseVitality, 50) }, + DarkKnightNumber or BladeKnightNumber or BladeMasterNumber => variant == 0 + ? new[] { (str, 62), (agi, 26), (vit, 8), (ene, 4) } + : new[] { (str, 50), (vit, 28), (agi, 18), (ene, 4) }, + DarkWizardNumber or SoulMasterNumber or GrandMasterNumber => + new[] { (ene, 66), (vit, 22), (agi, 8), (str, 4) }, + FairyElfNumber or MuseElfNumber or HighElfNumber => variant == 0 + ? new[] { (agi, 62), (vit, 23), (ene, 10), (str, 5) } + : new[] { (ene, 65), (vit, 22), (agi, 8), (str, 5) }, + MagicGladiatorNumber or DuelMasterNumber => variant == 0 + ? new[] { (str, 57), (agi, 22), (vit, 15), (ene, 6) } + : new[] { (ene, 58), (vit, 26), (agi, 11), (str, 5) }, + DarkLordNumber or LordEmperorNumber => + new[] { (str, 38), (cmd, 30), (vit, 22), (agi, 8), (ene, 2) }, + SummonerNumber or BloodySummonerNumber or DimensionMasterNumber => + new[] { (ene, 64), (vit, 24), (agi, 8), (str, 4) }, + RageFighterNumber or FistMasterNumber => + new[] { (str, 45), (vit, 35), (ene, 20) }, + _ => new[] { (GetMainDamageStat(characterClass), 50), (vit, 50) }, }; } + /// + /// The bot's personal vitality target on reset-meta servers: how many points it invests into + /// vitality over its whole career (100..500, rolled deterministically from the character name, + /// so the population gets a natural spread from glassy to sturdy and every bot keeps its roll + /// across restarts and resets). The endgame players such servers breed leave vitality almost + /// untouched - shield and agility-based defense tank instead - so the target is intentionally low. + /// + /// The character name. + public static int GetVitalityTarget(string characterName) + { + var sum = characterName.Aggregate(0, (acc, c) => acc + c); + return 100 + ((sum * 7919) % 401); + } + /// /// Splits the given points proportionally to the class's stat weights, returning whole-point - /// amounts which sum up to (the remainder goes to the first stat). + /// amounts which sum up to - unless capacities cut it short. The + /// optional callback limits how many points a stat may still take + /// (its on fun servers, the vitality target on + /// reset-meta servers); a filled stat drops out of the split and its share flows to the remaining + /// stats over subsequent rounds. When every stat is full, the rest of the points stay unassigned, + /// like for a maxed-out human character. /// /// The number of points to split. /// The stat weights of the class. - public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints(int points, IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights) + /// Optionally resolves how many more points a stat can take; null means unlimited. + public static IEnumerable<(AttributeDefinition Stat, int Amount)> SplitPoints( + int points, + IReadOnlyList<(AttributeDefinition Stat, int Weight)> weights, + Func? capacityOf = null) { - var totalWeight = weights.Sum(w => w.Weight); - if (points <= 0 || totalWeight <= 0) + if (points <= 0 || weights.Count == 0) { yield break; } - var assigned = 0; - for (var i = 1; i < weights.Count; i++) + var allocated = new int[weights.Count]; + var capacity = new long[weights.Count]; + for (var i = 0; i < weights.Count; i++) + { + capacity[i] = Math.Max(0, capacityOf?.Invoke(weights[i].Stat) ?? long.MaxValue); + } + + var remaining = points; + while (remaining > 0) { - var amount = points * weights[i].Weight / totalWeight; - assigned += amount; - if (amount > 0) + var activeTotalWeight = 0; + var firstActive = -1; + for (var i = 0; i < weights.Count; i++) { - yield return (weights[i].Stat, amount); + if (weights[i].Weight > 0 && allocated[i] < capacity[i]) + { + activeTotalWeight += weights[i].Weight; + if (firstActive < 0) + { + firstActive = i; + } + } } + + if (activeTotalWeight <= 0) + { + break; // every stat is at its capacity - the rest stays unspent. + } + + var assignedThisRound = 0; + for (var i = 0; i < weights.Count; i++) + { + if (weights[i].Weight <= 0 || allocated[i] >= capacity[i]) + { + continue; + } + + var share = (int)Math.Min((long)remaining * weights[i].Weight / activeTotalWeight, capacity[i] - allocated[i]); + allocated[i] += share; + assignedThisRound += share; + } + + if (assignedThisRound == 0) + { + // Rounding tail (fewer points left than active stats): the primary stat takes it. + var tail = (int)Math.Min(remaining, capacity[firstActive] - allocated[firstActive]); + allocated[firstActive] += tail; + assignedThisRound = tail; + if (assignedThisRound == 0) + { + break; + } + } + + remaining -= assignedThisRound; } - yield return (weights[0].Stat, points - assigned); + for (var i = 0; i < weights.Count; i++) + { + if (allocated[i] > 0) + { + yield return (weights[i].Stat, allocated[i]); + } + } } /// diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs index b0d554684..9c19d3737 100644 --- a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -8,6 +8,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using Microsoft.Extensions.Logging; using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.PlayerActions.Character; using MUnique.OpenMU.GameLogic.PlugIns; using MUnique.OpenMU.PlugIns; @@ -92,14 +93,40 @@ private void EvolveClassIfDue(Player player) private async ValueTask SpendStatPointsAsync(Player player) { var character = player.SelectedCharacter!; + var characterClass = character.CharacterClass!; var points = character.LevelUpPoints; if (points <= 0) { return; } - var weights = BotProgression.GetStatWeights(character.CharacterClass!); - foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights)) + var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null; + var weights = BotProgression.GetStatWeights(characterClass, character.Name, resetMeta); + var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null; + + // Mirrors the capacity checks of the stat-increase action (a stat's configured maximum on fun + // servers), plus the bot's personal vitality target on reset-meta servers: a full stat drops + // out of the split, so its share flows into the rest of the build instead of getting lost. + long CapacityOf(AttributeDefinition stat) + { + var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat); + var current = (long)(player.Attributes?[stat] ?? 0f); + var capacity = long.MaxValue; + if (classBase?.Attribute?.MaximumValue is { } maximumValue) + { + capacity = (long)maximumValue - current; + } + + if (vitalityTarget is { } target && stat == Stats.BaseVitality) + { + var invested = current - (long)(classBase?.BaseValue ?? 0f); + capacity = Math.Min(capacity, target - invested); + } + + return capacity; + } + + foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf)) { if (amount > 0) { diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 930b63933..a5864bea2 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -27,8 +27,10 @@ public sealed class CombatHandler /// See : 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. /// - private const float SafeHitHealthShare = 0.20f; + private const float SafeHitHealthShare = 0.15f; /// /// See : the bot's attack power must exceed the monster's defense by this diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs new file mode 100644 index 000000000..fd943b8eb --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs @@ -0,0 +1,77 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Offline; + +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests for : the point split with capacities and the per-bot rolls. +/// +[TestFixture] +public class BotProgressionTests +{ + /// + /// Tests that the split assigns all points proportionally when nothing is capped. + /// + [Test] + public void SplitPoints_AssignsAllPointsProportionally() + { + var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) }; + + var result = BotProgression.SplitPoints(1000, weights).ToDictionary(r => r.Stat, r => r.Amount); + + Assert.That(result.Values.Sum(), Is.EqualTo(1000)); + Assert.That(result[Stats.BaseAgility], Is.EqualTo(350)); + Assert.That(result[Stats.BaseVitality], Is.EqualTo(50)); + Assert.That(result[Stats.BaseStrength], Is.EqualTo(600)); + } + + /// + /// Tests that a capped stat drops out of the split and its share flows to the remaining stats. + /// + [Test] + public void SplitPoints_CappedStatOverflowsToOthers() + { + var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 35), (Stats.BaseVitality, 5) }; + long CapacityOf(AttributeDefinition stat) => stat == Stats.BaseVitality ? 10 : long.MaxValue; + + var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount); + + Assert.That(result[Stats.BaseVitality], Is.EqualTo(10)); + Assert.That(result.Values.Sum(), Is.EqualTo(1000)); + Assert.That(result[Stats.BaseStrength] + result[Stats.BaseAgility], Is.EqualTo(990)); + } + + /// + /// Tests that points stay unassigned when every stat is at its capacity, like for a maxed character. + /// + [Test] + public void SplitPoints_AllCapped_LeavesPointsUnassigned() + { + var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 40) }; + long CapacityOf(AttributeDefinition stat) => 25; + + var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount); + + Assert.That(result.Values.Sum(), Is.EqualTo(50)); + Assert.That(result.Values, Is.All.EqualTo(25)); + } + + /// + /// Tests that the vitality target roll stays within 100..500 and is stable for the same name. + /// + [Test] + public void GetVitalityTarget_IsStableAndWithinRange() + { + foreach (var name in new[] { "Kaeoris", "Milynara", "Hallin", "Oriwen", "X" }) + { + var target = BotProgression.GetVitalityTarget(name); + Assert.That(target, Is.InRange(100, 500), name); + Assert.That(BotProgression.GetVitalityTarget(name), Is.EqualTo(target), name); + } + } +} From 582a07bd28c61a7297c37d9927ac20827bad18e1 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 06:18:44 +0200 Subject: [PATCH 29/60] Let bots attack players only when it is free of PK consequences A bot's grudge memory (self-defense priority and the revenge march) deliberately outlives the game's one-minute self-defense window. Striking a remembered aggressor outside that window is an unprovoked attack, though: the engine escalates the bot's hero state, and a few kills turn the bot into an outlaw - a free kill forever, with the warp command blocked. Lab measurements confirmed three bots already at the player-kill-warning stage after a night of fighting. The new BotPvpRules.IsLegalPvpTarget is the single source of truth for bot PvP legality: a player may be struck only while the game's own self-defense window is active (with a safety margin, since every hit renews it) or when the target is already an outlaw. The combat handler applies it when picking the aggressor as target, when validating a kept target each tick (the window can expire mid-fight), and right before every strike. The grudge itself remains untouched - it still decides whom the bot prioritizes and where it marches, but never entitles a strike. --- src/GameLogic/Bots/BotPvpRules.cs | 57 ++++++++++++++++++++++++++ src/GameLogic/Offline/CombatHandler.cs | 30 ++++++++++++-- 2 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/GameLogic/Bots/BotPvpRules.cs diff --git a/src/GameLogic/Bots/BotPvpRules.cs b/src/GameLogic/Bots/BotPvpRules.cs new file mode 100644 index 000000000..b84a62a4a --- /dev/null +++ b/src/GameLogic/Bots/BotPvpRules.cs @@ -0,0 +1,57 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// The single source of truth for when a server-side bot may attack a player. +/// +/// +/// Invariant: a bot must never escalate its own . A bot which turns outlaw +/// is a broken toy - it can be killed penalty-free forever, loses the warp command, and visibly +/// marks itself as a misbehaving AI. The game escalates the killer's hero state (see +/// Player.AfterKilledPlayerAsync) unless the victim is already an outlaw or the kill happened +/// in active self-defense, so those two cases are exactly - and exclusively - what this rule allows. +/// The bot's grudge memory (, ~5 minutes, and the +/// revenge march after a death) is deliberately longer than the game's self-defense window: the +/// grudge only decides WHOM the bot prioritizes and where it walks; whether it may actually strike +/// is decided here, per attack, against the game's own rules. +/// +public static class BotPvpRules +{ + /// + /// How much of the self-defense window must still remain for an attack to count as safe. The + /// legality check runs when the attack is issued, but the kill (and with it the game's own + /// self-defense evaluation) can land moments later - without a margin, a final blow right at + /// the window's edge would escalate the bot's hero state after all. While the player keeps + /// attacking, every hit renews the window, so the margin never interrupts an ongoing fight. + /// + private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3); + + /// + /// Determines whether the bot may legally attack the given player, i.e. without any risk of + /// escalating the bot's own . + /// + /// The bot (or offline player) which wants to attack. + /// The player it wants to attack. + /// true if attacking is free of PK consequences; otherwise, false. + public static bool IsLegalPvpTarget(Player bot, Player target) + { + // Outlaws are fair game for everyone - killing them never escalates the killer's state. + if (target.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage) + { + return true; + } + + // Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed + // (attacker, defender) and renewed on every hit by the SelfDefensePlugIn). + if (bot.GameContext.SelfDefenseState.TryGetValue((target, bot), out var timeout) + && timeout > DateTime.UtcNow.Add(SelfDefenseSafetyMargin)) + { + return true; + } + + return false; + } +} diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index a5864bea2..967a5db58 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -339,6 +339,13 @@ private async ValueTask ExecuteAttackAsync(IAttackable target) 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) @@ -366,10 +373,14 @@ private void RefreshTarget() // 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. + // 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)) + && aggressor.IsInRange(this._player.Position, this.HuntingRange * 2) + && BotPvpRules.IsLegalPvpTarget(this._player, aggressor)) { this._currentTarget = aggressor; return; @@ -420,6 +431,14 @@ private bool IsTargetInAttackRange(IAttackable target, byte range) 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 @@ -489,8 +508,11 @@ await this._player.ForEachWorldObserverAsync( // A player target (the self-defense aggressor) is hit by the area skill as well - but ONLY // the target itself. 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. - if (target is Player playerTarget && playerTarget.IsAlive && !playerTarget.IsAtSafezone()) + // bot's self-defense must never splash uninvolved players, no matter what it casts. The + // legality re-check right at the strike closes the last race: the target was legal when it + // was picked, but the self-defense window may have run out in the meantime. + if (target is Player playerTarget && playerTarget.IsAlive && !playerTarget.IsAtSafezone() + && BotPvpRules.IsLegalPvpTarget(this._player, playerTarget)) { await playerTarget.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); } From a4e181e5462c592ab667d51d446143be54f33659 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 06:36:05 +0200 Subject: [PATCH 30/60] Let bots upgrade their gear with looted jewels After a finished merchant trade - a rare, safe moment in town - a bot now spends up to two of its looted jewels on its own equipment, through the regular item consume action with the same validations, success rates and failure penalties as for a human player. Since the consume handlers refuse to modify equipped items, the bot takes the piece off into the backpack, applies the jewel and wears it again, whatever the outcome. The policy mirrors common player behavior: a Jewel of Bless (always succeeds) pushes the weakest equipped piece towards +6; a Jewel of Soul is only risked above +6 with a spare in stock, preferring lucky items for the success bonus; a Jewel of Life is used sparingly - at most once per trip - on gear that is already +6 or better. Progress is saved right away, so a rolled Soul/Life outcome can't be replayed by losing it to a crash. --- src/GameLogic/Bots/BotJewelHandler.cs | 209 ++++++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 8 +- .../BotJewelHandlerTest.cs | 172 ++++++++++++++ 3 files changed, 388 insertions(+), 1 deletion(-) create mode 100644 src/GameLogic/Bots/BotJewelHandler.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs diff --git a/src/GameLogic/Bots/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs new file mode 100644 index 000000000..3b5dc188b --- /dev/null +++ b/src/GameLogic/Bots/BotJewelHandler.cs @@ -0,0 +1,209 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.ItemConsumeActions; +using MUnique.OpenMU.GameLogic.PlayerActions.Items; + +/// +/// Lets a bot invest its looted jewels into its own gear like a real player would: after a shopping +/// trip (a rare, safe moment in town), it takes a piece of equipment off, applies a Jewel of Bless, +/// Soul or Life through the regular - the same validations, success +/// rates and failure penalties as for a human - and puts the piece back on. +/// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped +/// piece towards +6; Soul (50%, +25% with luck, drops the level on failure) is only risked above +6 +/// with a spare in stock; Life (50%, removes the option on failure) is used sparingly on already +/// upgraded gear. Only a couple of jewels are spent per trip, so the hoard lasts - and drops for +/// players hunting the bot. +/// +internal static class BotJewelHandler +{ + /// Upper bound of jewel consumptions per shopping trip - a player doesn't burn the whole hoard at once. + private const int MaxUsesPerTrip = 2; + + /// The Jewel of Bless upgrades item levels 0..5 (see BlessJewelConsumeHandlerPlugIn). + private const byte BlessMaxTargetLevel = 5; + + /// The Jewel of Soul risk window: +6..+8 (a failure above +6 resets the item to +0!). + private const byte SoulMinTargetLevel = 6; + + /// The Jewel of Soul upgrades item levels up to 8 (see SoulJewelConsumeHandlerPlugIn). + private const byte SoulMaxTargetLevel = 8; + + /// Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve. + private const int MinSoulStock = 2; + + /// Life is only worth risking on gear which already proved worth upgrading. + private const byte LifeMinTargetLevel = 6; + + /// Only risk a Life with at least this many in stock. + private const int MinLifeStock = 2; + + private static readonly ItemConsumeAction ConsumeAction = new(); + private static readonly MoveItemAction MoveAction = new(); + + /// + /// Spends up to looted jewels on the bot's own equipment. Call this + /// right after a finished merchant trade: the bot stands in the safezone, the NPC dialog is closed + /// (player state is back at EnteredWorld, which the consume handlers require), and the + /// navigator's shopping cooldown provides the rare, player-like cadence. + /// + /// The bot player. + public static async ValueTask TryUpgradeGearAsync(OfflinePlayer player) + { + if (player.Inventory is null) + { + return; + } + + var uses = 0; + var lifeUsed = false; + while (uses < MaxUsesPerTrip && PlanNextUse(player, lifeUsed) is { } plan) + { + if (!await ApplyJewelAsync(player, plan.Jewel, plan.Target).ConfigureAwait(false)) + { + break; + } + + uses++; + lifeUsed |= plan.IsLife; + } + + if (uses > 0) + { + try + { + // Persist right away like after a bot reset - a rolled Soul/Life outcome shouldn't be + // replayable by losing it to a crash before the next periodic save. + await player.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after using jewels; the periodic save will retry.", player.Name); + } + } + } + + /// + /// Picks the next (jewel, equipped target item) pair according to the player-like policy, or + /// null when nothing sensible is left to do. Pure decision logic - exposed for unit tests. + /// + /// The bot player. + /// Whether a Jewel of Life was already used this trip (at most one). + internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player player, bool lifeUsed) + { + if (player.Inventory is not { } inventory) + { + return null; + } + + var backpack = inventory.Items.Where(i => i.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex).ToList(); + var equipped = inventory.Items + .Where(i => i.ItemSlot <= InventoryConstants.LastEquippableItemSlotIndex + && i.Definition?.IsAmmunition == false) + .ToList(); + + var blessStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfBless)).ToList(); + var soulStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfSoul)).ToList(); + var lifeStock = backpack.Where(i => IsJewel(i, ItemConstants.JewelOfLife)).ToList(); + + // 1. Bless - free progress: push the weakest equipped piece towards +6. + if (blessStock.Count > 0 + && equipped.Where(i => i.CanLevelBeUpgraded() && i.Level <= BlessMaxTargetLevel) + .OrderBy(i => i.Level) + .FirstOrDefault() is { } blessTarget) + { + return (blessStock[0], blessTarget, false); + } + + // 2. Soul - risky: only with a spare in stock; luck (+25% success) makes an item the preferred target. + if (soulStock.Count >= MinSoulStock + && equipped.Where(i => i.CanLevelBeUpgraded() && i.Level is >= SoulMinTargetLevel and <= SoulMaxTargetLevel) + .OrderByDescending(HasLuck) + .ThenBy(i => i.Level) + .FirstOrDefault() is { } soulTarget) + { + return (soulStock[0], soulTarget, false); + } + + // 3. Life - sparingly: at most one per trip, only on gear that is already +6 or better. Whether + // the item can actually carry the option is the consume handler's call; a rejected consume + // keeps the jewel. + if (!lifeUsed + && lifeStock.Count >= MinLifeStock + && equipped.Where(i => i.IsWearable() && i.Level >= LifeMinTargetLevel) + .OrderByDescending(i => i.Level) + .FirstOrDefault() is { } lifeTarget) + { + return (lifeStock[0], lifeTarget, true); + } + + return null; + } + + /// + /// Applies one jewel to one equipped item the way a player does it: take the piece off into a free + /// backpack slot (the consume handlers refuse to modify equipped items), consume the jewel on it + /// through the regular action, and wear the piece again - whatever the outcome, even a Soul + /// failure's downgraded item goes back on. + /// + /// true, if the jewel was actually consumed. + private static async ValueTask ApplyJewelAsync(OfflinePlayer player, Item jewel, Item target) + { + var inventory = player.Inventory!; + var equipSlot = target.ItemSlot; + var freeSlot = inventory.FreeSlots.FirstOrDefault(s => s > InventoryConstants.LastEquippableItemSlotIndex, (byte)0); + if (freeSlot == 0) + { + return false; + } + + await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(freeSlot) != target) + { + // The unequip was rejected - don't force it. + return false; + } + + var jewelSlot = jewel.ItemSlot; + var levelBefore = target.Level; + try + { + await ConsumeAction.HandleConsumeRequestAsync(player, jewelSlot, freeSlot, FruitUsage.Undefined).ConfigureAwait(false); + } + finally + { + // Wear the piece again in any case; the move action re-checks the requirements itself. + await MoveAction.MoveItemAsync(player, freeSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false); + } + + var consumed = inventory.GetItem(jewelSlot) != jewel; + if (consumed) + { + player.Logger.LogInformation( + "Bot '{Name}' used '{Jewel}' on '{Item}': level {Before} -> {After}.", + player.Name, + jewel.Definition?.Name, + target, + levelBefore, + target.Level); + } + + return consumed; + } + + private static bool IsJewel(Item item, ItemIdentifier identifier) + { + return item.Definition is { } definition + && identifier == new ItemIdentifier(definition.Number, definition.Group); + } + + private static bool HasLuck(Item item) + { + return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck); + } +} diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 5a71bc486..a1c274fbd 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -509,7 +509,13 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) return true; } - await BotShoppingHandler.TryTradeAsync(this._player, map, target).ConfigureAwait(false); + if (await BotShoppingHandler.TryTradeAsync(this._player, map, target).ConfigureAwait(false)) + { + // Still standing safely at the merchant with the dialog closed - the player-like moment + // to spend a few looted jewels on the own gear (see BotJewelHandler). + await BotJewelHandler.TryUpgradeGearAsync(this._player).ConfigureAwait(false); + } + this._shoppingTarget = null; this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown; this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck" diff --git a/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs new file mode 100644 index 000000000..bf65a4470 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs @@ -0,0 +1,172 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests the jewel usage policy of - which jewel a bot picks for which +/// equipped item; the actual consumption goes through the regular consume handlers and is covered by +/// . +/// +[TestFixture] +public class BotJewelHandlerTest +{ + private const byte FirstBackpackSlot = 12; + + /// + /// A Bless in stock and an equipped piece below +6: the weakest piece is chosen for the Bless. + /// + [Test] + public async ValueTask PrefersBlessOnWeakestUpgradeableItemAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var strongPiece = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 4).ConfigureAwait(false); + var weakPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false); + var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false); + + var plan = BotJewelHandler.PlanNextUse(player, false); + + Assert.That(plan, Is.Not.Null); + Assert.That(plan!.Value.Jewel, Is.SameAs(bless)); + Assert.That(plan.Value.Target, Is.SameAs(weakPiece)); + Assert.That(plan.Value.IsLife, Is.False); + } + + /// + /// All gear at +6 or above: a Soul is only risked with a spare in stock. + /// + /// The number of souls in the backpack. + /// Whether a jewel use is expected. + [TestCase(1, false)] + [TestCase(2, true)] + public async ValueTask RisksSoulOnlyWithSpareStockAsync(int soulCount, bool expectsUse) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false); + for (var i = 0; i < soulCount; i++) + { + await AddJewelAsync(player, (byte)(FirstBackpackSlot + i), ItemConstants.JewelOfSoul).ConfigureAwait(false); + } + + var plan = BotJewelHandler.PlanNextUse(player, false); + + Assert.That(plan.HasValue, Is.EqualTo(expectsUse)); + } + + /// + /// The Soul prefers a lucky target (its success bonus) over a lower-level one without luck. + /// + [Test] + public async ValueTask SoulPrefersLuckyItemAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 6).ConfigureAwait(false); + var luckyPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 7, withLuck: true).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false); + + var plan = BotJewelHandler.PlanNextUse(player, false); + + Assert.That(plan, Is.Not.Null); + Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece)); + } + + /// + /// Life is the last resort and is planned at most once per trip. + /// + /// Whether a life was already used within the trip. + /// Whether a jewel use is expected. + [TestCase(false, true)] + [TestCase(true, false)] + public async ValueTask UsesLifeAtMostOncePerTripAsync(bool lifeAlreadyUsed, bool expectsUse) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var target = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 9).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfLife).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfLife).ConfigureAwait(false); + + var plan = BotJewelHandler.PlanNextUse(player, lifeAlreadyUsed); + + Assert.That(plan.HasValue, Is.EqualTo(expectsUse)); + if (expectsUse) + { + Assert.That(plan!.Value.Target, Is.SameAs(target)); + Assert.That(plan.Value.IsLife, Is.True); + } + } + + /// + /// Without any applicable jewel or target, nothing is planned. + /// + [Test] + public async ValueTask PlansNothingWithoutJewelsAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 2).ConfigureAwait(false); + + var plan = BotJewelHandler.PlanNextUse(player, false); + + Assert.That(plan, Is.Null); + } + + private static async ValueTask AddEquippedItemAsync(Player player, byte slot, byte level, bool withLuck = false) + { + var item = new Mock(); + item.SetupAllProperties(); + var itemOptions = new List(); + item.Setup(i => i.ItemOptions).Returns(itemOptions); + item.Setup(i => i.ItemSetGroups).Returns(new List()); + var definition = new Mock(); + definition.SetupAllProperties(); + var itemSlot = new Mock(); + itemSlot.Setup(s => s.ItemSlots).Returns(new List { slot }); + definition.Setup(d => d.ItemSlot).Returns(itemSlot.Object); + item.Object.Definition = definition.Object; + item.Object.Definition.Width = 1; + item.Object.Definition.Height = 1; + item.Object.Definition.MaximumItemLevel = 15; + item.Object.Definition.Durability = 1; + item.Object.Durability = 1; + item.Object.Level = level; + + if (withLuck) + { + var optionLink = new Mock(); + optionLink.SetupAllProperties(); + var option = new Mock(); + option.SetupAllProperties(); + option.Object.OptionType = ItemOptionTypes.Luck; + optionLink.Object.ItemOption = option.Object; + itemOptions.Add(optionLink.Object); + } + + await player.Inventory!.AddItemAsync(slot, item.Object).ConfigureAwait(false); + return item.Object; + } + + private static async ValueTask AddJewelAsync(Player player, int slot, ItemIdentifier identifier) + { + var jewel = new Item + { + Definition = new ItemDefinition + { + Number = identifier.Number ?? 0, + Group = identifier.Group, + Width = 1, + Height = 1, + }, + Durability = 1, + }; + await player.Inventory!.AddItemAsync((byte)slot, jewel).ConfigureAwait(false); + return jewel; + } +} From 68c73a7d97d1e96e75c2beb444d6697fb42cbd27 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 07:36:21 +0200 Subject: [PATCH 31/60] Let bots accept party invitations from players A new IMuHelperSettings.AutoAcceptAnyone flag (following the maintainer's suggestion; a default interface member, so existing implementations are untouched) makes the receiver accept party requests from anyone, not just friends or guild mates. The bot settings enable it, and all bot-specific behavior lives in the new BotPartyHandler: the invitation is answered after a human-like 2-5 second delay in the bot's own tick, re-validated at that point (the inviter may have died, left, or joined another party), and only taken at all when the bot is available - not already grouped or invited, not on a shopping errand, no armed revenge, and the inviter's effective level within a sensible range. In the party, the existing follow logic applies (the bot follows the human leader like any party member and the elf heals the group), with two new rules: a due character reset is deferred until the party is over, and a leader moving to a map the bot's plain level cannot legally enter makes the bot leave the group instead of sneaking in - level gates map access, in a party like anywhere else. After 10-20 minutes the bot gets bored and politely leaves; a bot which logs out for the presence rotation already left its party beforehand. Bot-only parties stay managed by the hourly re-formation and know no boredom. --- src/GameLogic/Bots/BotMuHelperSettings.cs | 7 + src/GameLogic/Bots/BotNavigator.cs | 35 +++ src/GameLogic/Bots/BotPartyHandler.cs | 186 ++++++++++++++++ src/GameLogic/Bots/PendingPartyInvite.cs | 13 ++ src/GameLogic/MuHelper/IMuHelperSettings.cs | 7 + src/GameLogic/MuHelper/PartyRequestHandler.cs | 7 + src/GameLogic/Offline/OfflinePlayer.cs | 25 +++ .../Party/BotPartyHandlerTest.cs | 204 ++++++++++++++++++ 8 files changed, 484 insertions(+) create mode 100644 src/GameLogic/Bots/BotPartyHandler.cs create mode 100644 src/GameLogic/Bots/PendingPartyInvite.cs create mode 100644 tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs diff --git a/src/GameLogic/Bots/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs index 085aacdf0..06fa4f851 100644 --- a/src/GameLogic/Bots/BotMuHelperSettings.cs +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -172,6 +172,13 @@ internal sealed class BotMuHelperSettings : IMuHelperSettings /// public bool AutoAcceptGuild => false; + /// + /// + /// Bots accept party invitations from any player, like a friendly stranger would - within the + /// safeguards applied by (level gap, not while busy, limited time). + /// + public bool AutoAcceptAnyone => true; + /// public bool FallbackBasicAttack => true; diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index a1c274fbd..910625bec 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -305,6 +305,10 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // Party bookkeeping: answer a pending invitation from a player once its human-like delay + // passed, and leave a party with a human again when the bot got bored (see BotPartyHandler). + await BotPartyHandler.ProcessAsync(this._player).ConfigureAwait(false); + // Make sure the bot always carries healing and mana potions. Without them the offline handlers // have nothing to drink: the bot dies to sustained damage, and casters degrade to weak melee // once their mana runs dry. This also tops up already-generated bots at runtime. @@ -504,6 +508,7 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) { // No way to the merchant from here - give up this trip. this._shoppingTarget = null; + this._player.IsOnShoppingTrip = false; } return true; @@ -517,6 +522,7 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) } this._shoppingTarget = null; + this._player.IsOnShoppingTrip = false; this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown; this._lastMoveUtc = DateTime.UtcNow; // standing at the shop is not "stuck" return true; @@ -545,6 +551,7 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) } this._shoppingTarget = merchantPosition; + this._player.IsOnShoppingTrip = true; this._player.Logger.LogInformation("Bot '{Name}' heads to the merchant for a shopping trip.", this._player.Name); return true; } @@ -624,6 +631,26 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) { if (!ReferenceEquals(leader.CurrentMap, map)) { + if (leader.CurrentMap is { } targetMap + && !this.TryGetLegalWarpGate(targetMap.Definition, out _) + && targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number) + { + // The leader moved to a map the bot's plain character level cannot legally enter + // (level gates map access, the same rule as everywhere else). Rather than trail + // behind unreachable or sneak in through a back door, the bot leaves the group. + if (this._player.Party is { } party) + { + this._player.Logger.LogInformation( + "Bot {Character} leaves its party: it cannot legally follow '{Leader}' to map {Map}.", + this._player.Name, + leader.Name, + targetMap.Definition.Name); + await party.KickMySelfAsync(this._player).ConfigureAwait(false); + } + + return true; + } + if (DateTime.UtcNow - this._lastWarpUtc >= FollowWarpCooldown && leader.CurrentMap is { } leaderMap && leaderMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } leaderGate) @@ -665,6 +692,14 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) /// True, if a reset was performed. private async ValueTask TryResetCharacterAsync() { + if (BotPartyHandler.HasHumanCompanion(this._player)) + { + // Not in the middle of a hunting session with a player: the reset would wipe the bot's + // strength and teleport it home, deserting the group. It resets right after the party + // ends (the boredom timer of BotPartyHandler bounds how long that takes). + return false; + } + if (BotResetHandler.GetResetConfiguration(this._player.GameContext) is not { } resetConfiguration || !BotResetHandler.IsResetDue(this._player, resetConfiguration)) { diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs new file mode 100644 index 000000000..978a5b284 --- /dev/null +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -0,0 +1,186 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// Lets a server-side bot party up with players who invite it (enabled by +/// ): the invitation is accepted after a short +/// human-like delay, and the bot then follows the leader like any party member (see the follow logic +/// in ) until it gets bored and politely leaves. Safeguards keep it +/// believable and abuse-free: no grouping across an absurd level gap, no acceptance while the bot is +/// on an errand (shopping trip) or has unfinished business (revenge), and the invitation is +/// re-validated when the delay has passed - the inviter may have joined another party or left. +/// +internal static class BotPartyHandler +{ + /// + /// The maximum difference of the reset-aware effective level (see + /// ) between the bot and the inviter. Within one + /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a + /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers + /// without the reset feature the plain levels always lie within this bound, matching the original + /// game, which does not restrict party formation by level either. + /// + private const int MaxEffectiveLevelGap = 500; + + /// Lower bound of the human-like delay before the bot answers an invitation. + private static readonly TimeSpan MinAcceptDelay = TimeSpan.FromSeconds(2); + + /// Upper bound of the human-like delay before the bot answers an invitation. + private static readonly TimeSpan MaxAcceptDelay = TimeSpan.FromSeconds(5); + + /// + /// Lower bound of the time the bot stays in a party with a human before it gets bored and leaves. + /// A player who groups a bot gets a companion for a decent hunting session, but not a permanent + /// follower - the bot has its own goals (its resets, its shopping, its own pace). + /// + private static readonly TimeSpan MinPartyDuration = TimeSpan.FromMinutes(10); + + /// Upper bound of the time the bot stays in a party with a human, see . + private static readonly TimeSpan MaxPartyDuration = TimeSpan.FromMinutes(20); + + /// + /// Schedules the acceptance of a party invitation to a bot, if the bot is available for it. + /// Called from the auto-accept criteria of . + /// + /// The invited player; only server-side bots schedule an accept. + /// The player who sent the party request. + /// Overrides the human-like random delay (used by tests). + /// True, if the invitation was taken and will be answered; false, if no criteria matched. + internal static async ValueTask TryScheduleAcceptAsync(Player receiver, Player requester, TimeSpan? acceptDelay = null) + { + if (receiver is not OfflinePlayer bot + || bot.Account?.IsBot != true + || bot.Party is not null + || bot.PendingPartyInvite is not null) + { + return false; + } + + if (bot.IsOnShoppingTrip || bot.HasRevengeIntent) + { + // Busy - a player in the middle of an errand or a grudge would not group up either. + return false; + } + + if (!IsRequesterEligible(bot, requester)) + { + return false; + } + + var delay = acceptDelay + ?? MinAcceptDelay + TimeSpan.FromMilliseconds(Rand.NextInt(0, (int)(MaxAcceptDelay - MinAcceptDelay).TotalMilliseconds + 1)); + + // Blocks a second concurrent inviter (the request action treats a set requester like a busy + // player) and is cleared again when the invitation is answered or dropped. + bot.LastPartyRequester = requester; + bot.PendingPartyInvite = new PendingPartyInvite(requester, DateTime.UtcNow + delay); + + // The same feedback a human invitee's request flow gives, so the inviter knows it went out. + await requester.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.RequestedPlayerForParty), bot.Name).ConfigureAwait(false); + bot.Logger.LogInformation("Bot '{Name}' accepts the party invitation of '{Requester}' in {Delay}.", bot.Name, requester.Name, delay); + return true; + } + + /// + /// Drives the bot's party behavior; called from the bot's regular evaluation tick. Answers a + /// pending invitation once its delay passed, and leaves the party again when the bot got bored + /// of grouping with a human (bot-only parties are exempt - they are managed by the hourly + /// re-formation of ). + /// + /// The bot. + internal static async ValueTask ProcessAsync(OfflinePlayer bot) + { + if (bot.PendingPartyInvite is { } invite && DateTime.UtcNow >= invite.AcceptAtUtc) + { + bot.PendingPartyInvite = null; + try + { + await AcceptInvitationAsync(bot, invite.Requester).ConfigureAwait(false); + } + finally + { + bot.LastPartyRequester = null; + } + } + + if (bot.Party is { } party && HasHumanCompanion(bot)) + { + bot.PartyBoredomAtUtc ??= DateTime.UtcNow + MinPartyDuration + + TimeSpan.FromSeconds(Rand.NextInt(0, (int)(MaxPartyDuration - MinPartyDuration).TotalSeconds + 1)); + if (DateTime.UtcNow >= bot.PartyBoredomAtUtc) + { + bot.PartyBoredomAtUtc = null; + bot.Logger.LogInformation("Bot '{Name}' got bored and leaves its party.", bot.Name); + await party.KickMySelfAsync(bot).ConfigureAwait(false); + } + } + else + { + bot.PartyBoredomAtUtc = null; + } + } + + /// + /// Determines whether the bot's party contains a human player (any live member which is not a + /// server-side ). + /// + /// The bot. + /// True, if a human player is in the bot's party. + internal static bool HasHumanCompanion(Player bot) + { + return bot.Party is { } party + && party.PartyList.OfType().Any(member => member is not OfflinePlayer); + } + + private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player requester) + { + // Re-validate: between the invitation and this answer, the bot may have grouped up (the hourly + // bot party formation) and the inviter may have died, left the game or joined another party. + if (bot.Party is not null || !IsRequesterEligible(bot, requester)) + { + bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name); + return; + } + + bool success; + if (requester.Party is { } requesterParty) + { + if (!Equals(requesterParty.PartyMaster, requester)) + { + // The inviter joined another party as a plain member in the meantime; it can no + // longer take the bot in. + return; + } + + success = await requesterParty.AddAsync(bot).ConfigureAwait(false); + } + else + { + // Like the regular party response: the requester becomes the master of the new party. + var party = bot.GameContext.PartyManager.CreateParty(); + success = await party.AddAsync(requester).ConfigureAwait(false) + && await party.AddAsync(bot).ConfigureAwait(false); + } + + if (success) + { + bot.Logger.LogInformation("Bot '{Name}' joined the party of '{Requester}'.", bot.Name, requester.Name); + } + } + + private static bool IsRequesterEligible(OfflinePlayer bot, Player requester) + { + if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld) + { + return false; + } + + var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester)); + return levelGap <= MaxEffectiveLevelGap; + } +} diff --git a/src/GameLogic/Bots/PendingPartyInvite.cs b/src/GameLogic/Bots/PendingPartyInvite.cs new file mode 100644 index 000000000..2ecd01c8c --- /dev/null +++ b/src/GameLogic/Bots/PendingPartyInvite.cs @@ -0,0 +1,13 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +/// +/// A party invitation from a player which a bot accepted, waiting for the human-like delay to pass +/// before the party is actually formed (see ). +/// +/// The player who invited the bot. +/// When the bot answers the invitation. +internal sealed record PendingPartyInvite(Player Requester, DateTime AcceptAtUtc); diff --git a/src/GameLogic/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs index fe717383e..2ade57c66 100644 --- a/src/GameLogic/MuHelper/IMuHelperSettings.cs +++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs @@ -150,6 +150,13 @@ public interface IMuHelperSettings /// Gets a value indicating whether to automatically accept requests from guild. bool AutoAcceptGuild { get; } + /// + /// Gets a value indicating whether to automatically accept party requests from anyone, not just + /// friends or guild mates. Defaults to false; used by server-side bots so they group up + /// with players who invite them (see Bots.BotPartyHandler for the applied safeguards). + /// + bool AutoAcceptAnyone => false; + /// Gets a value indicating whether to use basic attack as fallback when the configured skill cannot be used. bool FallbackBasicAttack { get; } diff --git a/src/GameLogic/MuHelper/PartyRequestHandler.cs b/src/GameLogic/MuHelper/PartyRequestHandler.cs index ff190b024..5f3419b4d 100644 --- a/src/GameLogic/MuHelper/PartyRequestHandler.cs +++ b/src/GameLogic/MuHelper/PartyRequestHandler.cs @@ -38,6 +38,13 @@ public static async ValueTask TryAutoAcceptPartyRequestAsync(Player receiv return true; } + if (settings.AutoAcceptAnyone && await Bots.BotPartyHandler.TryScheduleAcceptAsync(receiver, requester).ConfigureAwait(false)) + { + // The actual accept happens shortly afterwards in the bot's own tick (a human-like delay); + // all bot-specific safeguards live in the handler, so this stays a thin criteria branch. + return true; + } + return false; } diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 06b156614..c7d3ed7fb 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -126,6 +126,31 @@ internal Player? RecentAggressor } } + /// + /// Gets or sets the pending party invitation from a player, scheduled by + /// and executed with a human-like delay in the bot's tick. + /// + internal Bots.PendingPartyInvite? PendingPartyInvite { get; set; } + + /// + /// 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 ). + /// + internal DateTime? PartyBoredomAtUtc { get; set; } + + /// + /// Gets or sets a value indicating whether the bot is currently on a shopping trip (walking to + /// or trading with a merchant), maintained by . While on an errand + /// the bot declines party invitations, like a busy player would. + /// + internal bool IsOnShoppingTrip { get; set; } + + /// + /// 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. + /// + internal bool HasRevengeIntent => this._revenge is not null; + /// /// Initializes the offline player by loading the account fresh from the database. /// diff --git a/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs b/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs new file mode 100644 index 000000000..1b79d638b --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Party/BotPartyHandlerTest.cs @@ -0,0 +1,204 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.Party; + +/// +/// Tests - how a server-side bot answers party invitations from +/// players and when it leaves the party again. +/// +[TestFixture] +public class BotPartyHandlerTest +{ + /// + /// The happy path: an eligible invitation is scheduled and, once processed, forms a party with + /// the inviter as its master. + /// + [Test] + public async ValueTask AcceptsInviteAndFormsPartyAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + + var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); + Assert.That(scheduled, Is.True); + Assert.That(bot.PendingPartyInvite, Is.Not.Null); + Assert.That(bot.LastPartyRequester, Is.SameAs(requester)); + + await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false); + + Assert.That(bot.Party, Is.Not.Null); + Assert.That(bot.Party!.PartyMaster, Is.SameAs(requester)); + Assert.That(requester.Party, Is.SameAs(bot.Party)); + Assert.That(bot.PendingPartyInvite, Is.Null); + Assert.That(bot.LastPartyRequester, Is.Null); + } + + /// + /// An inviter whose effective level is too far from the bot's is declined - the group would only + /// be a power-leveling service. + /// + [Test] + public async ValueTask RejectsTooLargeLevelGapAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + requester.Attributes![Stats.Level] = 700; + + var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); + + Assert.That(scheduled, Is.False); + Assert.That(bot.PendingPartyInvite, Is.Null); + Assert.That(bot.LastPartyRequester, Is.Null); + } + + /// + /// A bot on a shopping errand declines the invitation, like a busy player would. + /// + [Test] + public async ValueTask RejectsWhileOnShoppingTripAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + bot.IsOnShoppingTrip = true; + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + + var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); + + Assert.That(scheduled, Is.False); + } + + /// + /// Only server-side bot accounts answer; a regular offline session of a human account does not. + /// + [Test] + public async ValueTask RejectsForNonBotAccountAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot", isBot: false).ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + + var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); + + Assert.That(scheduled, Is.False); + } + + /// + /// The invitation is re-validated when the delay passed: an inviter who joined another party as a + /// plain member in the meantime cannot take the bot in anymore. + /// + [Test] + public async ValueTask CancelsWhenRequesterJoinedAnotherPartyAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + var thirdPlayer = await CreateHumanAsync(gameContext, "Third").ConfigureAwait(false); + + var scheduled = await BotPartyHandler.TryScheduleAcceptAsync(bot, requester, TimeSpan.Zero).ConfigureAwait(false); + Assert.That(scheduled, Is.True); + + // Meanwhile the inviter joins another party as a plain member (the third player is master). + var otherParty = gameContext.PartyManager.CreateParty(); + await otherParty.AddAsync(thirdPlayer).ConfigureAwait(false); + await otherParty.AddAsync(requester).ConfigureAwait(false); + + await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false); + + Assert.That(bot.Party, Is.Null); + Assert.That(bot.PendingPartyInvite, Is.Null); + Assert.That(bot.LastPartyRequester, Is.Null); + Assert.That(otherParty.PartyList, Does.Not.Contain(bot)); + } + + /// + /// After its rolled party time is up, the bot gets bored of the party with a human and leaves. + /// + [Test] + public async ValueTask LeavesPartyWithHumanWhenBoredAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + var party = gameContext.PartyManager.CreateParty(); + await party.AddAsync(requester).ConfigureAwait(false); + await party.AddAsync(bot).ConfigureAwait(false); + + bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1); + await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false); + + Assert.That(bot.Party, Is.Null); + Assert.That(bot.PartyBoredomAtUtc, Is.Null); + } + + /// + /// Bot-only parties are managed by the hourly re-formation instead - no boredom timer runs, and + /// the bot stays with its group. + /// + [Test] + public async ValueTask StaysInBotOnlyPartyAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var otherBot = await CreateBotAsync(gameContext, "OtherBot").ConfigureAwait(false); + var party = gameContext.PartyManager.CreateParty(); + await party.AddAsync(otherBot).ConfigureAwait(false); + await party.AddAsync(bot).ConfigureAwait(false); + + bot.PartyBoredomAtUtc = DateTime.UtcNow - TimeSpan.FromSeconds(1); + await BotPartyHandler.ProcessAsync(bot).ConfigureAwait(false); + + Assert.That(bot.Party, Is.SameAs(party)); + Assert.That(bot.PartyBoredomAtUtc, Is.Null); + } + + /// + /// The full wiring: a party request through the regular request action reaches the bot via the + /// criteria and schedules the delayed answer. + /// + [Test] + public async ValueTask PartyRequestActionSchedulesInviteForBotAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + var requester = await CreateHumanAsync(gameContext, "Human").ConfigureAwait(false); + requester.Observers.Add(bot); + + var action = new PartyRequestAction(); + await action.HandlePartyRequestAsync(requester, bot).ConfigureAwait(false); + + Assert.That(bot.PendingPartyInvite, Is.Not.Null); + Assert.That(bot.PendingPartyInvite!.Requester, Is.SameAs(requester)); + Assert.That(bot.LastPartyRequester, Is.SameAs(requester)); + } + + private static async ValueTask CreateBotAsync(IGameContext gameContext, string name, bool isBot = true) + { + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + bot.SelectedCharacter!.Name = name; + bot.IsAlive = true; + bot.Account!.IsBot = isBot; + bot.MuHelperSettings = new BotMuHelperSettings(); + return bot; + } + + private static async ValueTask CreateHumanAsync(IGameContext gameContext, string name) + { + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + player.SelectedCharacter!.Name = name; + player.IsAlive = true; + return player; + } +} From 1661356abad96204f6a710e18b21ccea500ccef1 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 07:45:55 +0200 Subject: [PATCH 32/60] Correct a wrong claim in the jewel handler doc Killed players don't drop their carried items in OpenMU (death only costs experience and money), so the per-trip jewel limit can't be justified with drops for the hunters - its actual point is the player-like pacing. --- src/GameLogic/Bots/BotJewelHandler.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Bots/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs index 3b5dc188b..4a9a1d402 100644 --- a/src/GameLogic/Bots/BotJewelHandler.cs +++ b/src/GameLogic/Bots/BotJewelHandler.cs @@ -17,8 +17,8 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped /// piece towards +6; Soul (50%, +25% with luck, drops the level on failure) is only risked above +6 /// with a spare in stock; Life (50%, removes the option on failure) is used sparingly on already -/// upgraded gear. Only a couple of jewels are spent per trip, so the hoard lasts - and drops for -/// players hunting the bot. +/// upgraded gear. Only a couple of jewels are spent per trip, so the upgrades trickle in over many +/// visits like for a player instead of the whole hoard being burned at once. /// internal static class BotJewelHandler { From ffbf727285fea68a6f8033e6e8392fae0ade97de Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 08:09:52 +0200 Subject: [PATCH 33/60] Correct stale and imprecise claims in bot code comments An audit of every factual claim in the bot comments against the engine found leftovers from before the PvP legality rule: four places still said the re-armed aggressor memory makes the bot attack its killer on sight, while the grudge only prioritizes the target and BotPvpRules decides per attack. Also: the post-reset level is whatever LevelAfterReset configures (not literally 10), the self-defense window renews per damaging hit only, the party level gap rationale now cites OpenMU's own party action instead of game folklore, and the seeded-evolution shortcut states its precondition. --- src/GameLogic/Bots/BotGenerator.cs | 5 +++-- src/GameLogic/Bots/BotNavigator.cs | 5 +++-- src/GameLogic/Bots/BotPartyHandler.cs | 4 ++-- src/GameLogic/Bots/BotPvpRules.cs | 7 ++++--- src/GameLogic/Bots/BotResetHandler.cs | 3 ++- src/GameLogic/Bots/BotRevengePlugIn.cs | 4 ++-- src/GameLogic/Offline/OfflinePlayer.cs | 6 ++++-- 7 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index ab7cb85c1..ccf2c1cf9 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -351,8 +351,9 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam // A character generated beyond the class evolution level was created as its second-generation // class right away - like a player who completed the class quest long ago. Everything downstream // (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset - // history evolved in its first cycle at the latest (it passed the evolution level on the way - // to the reset level), regardless of its current in-cycle level. + // history evolved in its first cycle at the latest - provided the reset's required level lies + // beyond the evolution level (the check below), which makes it pass the evolution on the way + // to its first reset regardless of its current in-cycle level. var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel; if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle) && BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 910625bec..482bfcfa4 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -560,8 +560,9 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) /// Marches the bot back to the place of its death while a revenge is armed (see /// ). The single attempt is spent as soon as /// the bot arrives or runs into its killer on the way - no extra combat logic is needed here, - /// because the re-armed aggressor memory already makes the combat handler attack the killer on - /// sight. Monsters along the route do not stop the march (self-defense still engages ones right + /// because the re-armed aggressor memory already keeps the combat handler prioritizing the + /// killer, struck only when legal (see ). + /// Monsters along the route do not stop the march (self-defense still engages ones right /// next to the bot); a bot that stopped to farm would never arrive within the revenge time. /// /// The current map. diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index 978a5b284..ed7f3be52 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -22,8 +22,8 @@ internal static class BotPartyHandler /// ) between the bot and the inviter. Within one /// reset worth of levels plus some slack, hunting together still makes sense for both; grouping a /// fresh character with a 15-resets veteran would only be a power-leveling service. On servers - /// without the reset feature the plain levels always lie within this bound, matching the original - /// game, which does not restrict party formation by level either. + /// without the reset feature the plain levels always lie within this bound, matching OpenMU's own + /// party action, which has no level gate at all. /// private const int MaxEffectiveLevelGap = 500; diff --git a/src/GameLogic/Bots/BotPvpRules.cs b/src/GameLogic/Bots/BotPvpRules.cs index b84a62a4a..2f6c8c4dc 100644 --- a/src/GameLogic/Bots/BotPvpRules.cs +++ b/src/GameLogic/Bots/BotPvpRules.cs @@ -12,7 +12,8 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// is a broken toy - it can be killed penalty-free forever, loses the warp command, and visibly /// marks itself as a misbehaving AI. The game escalates the killer's hero state (see /// Player.AfterKilledPlayerAsync) unless the victim is already an outlaw or the kill happened -/// in active self-defense, so those two cases are exactly - and exclusively - what this rule allows. +/// in active self-defense (duels and rival-guild wars are exempt as well, but bots have neither), +/// so those two cases are exactly what this rule allows. /// The bot's grudge memory (, ~5 minutes, and the /// revenge march after a death) is deliberately longer than the game's self-defense window: the /// grudge only decides WHOM the bot prioritizes and where it walks; whether it may actually strike @@ -25,7 +26,7 @@ public static class BotPvpRules /// legality check runs when the attack is issued, but the kill (and with it the game's own /// self-defense evaluation) can land moments later - without a margin, a final blow right at /// the window's edge would escalate the bot's hero state after all. While the player keeps - /// attacking, every hit renews the window, so the margin never interrupts an ongoing fight. + /// attacking, every damaging hit renews the window, so the margin never interrupts an ongoing fight. /// private static readonly TimeSpan SelfDefenseSafetyMargin = TimeSpan.FromSeconds(3); @@ -45,7 +46,7 @@ public static bool IsLegalPvpTarget(Player bot, Player target) } // Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed - // (attacker, defender) and renewed on every hit by the SelfDefensePlugIn). + // (attacker, defender) and renewed on every damaging hit by the SelfDefensePlugIn). if (bot.GameContext.SelfDefenseState.TryGetValue((target, bot), out var timeout) && timeout > DateTime.UtcNow.Add(SelfDefenseSafetyMargin)) { diff --git a/src/GameLogic/Bots/BotResetHandler.cs b/src/GameLogic/Bots/BotResetHandler.cs index 3209f3031..f1ef2b484 100644 --- a/src/GameLogic/Bots/BotResetHandler.cs +++ b/src/GameLogic/Bots/BotResetHandler.cs @@ -35,7 +35,8 @@ internal static class BotResetHandler /// /// Gets the player's effective level for bot decisions: on a reset server a freshly reset - /// character is level 10 again but fights with the accumulated power of all its resets, so the + /// character is back at the configured but + /// fights with the accumulated power of all its resets, so the /// plain level would misjudge it everywhere (target safety, map choice, party matching). Each /// reset counts as the level span it took (). /// Without the reset feature this is simply the character level. diff --git a/src/GameLogic/Bots/BotRevengePlugIn.cs b/src/GameLogic/Bots/BotRevengePlugIn.cs index b48089345..d65378224 100644 --- a/src/GameLogic/Bots/BotRevengePlugIn.cs +++ b/src/GameLogic/Bots/BotRevengePlugIn.cs @@ -14,8 +14,8 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// its death after respawning and take revenge on the killer. A bot which shrugs off being killed /// and calmly heads for the next hunting ground is an obvious bot giveaway - a real player comes /// back angry. The return march happens in the ; the counter-attack in -/// the offline , whose re-armed aggressor memory makes the bot engage -/// the killer on sight. +/// the offline , whose re-armed aggressor memory keeps the killer +/// prioritized - struck only once the game's own rules make it legal (see ). /// [PlugIn] [Display(Name = "Bot revenge", Description = "Makes server-side bots return to their death site and take revenge on the player who killed them.")] diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index c7d3ed7fb..4bebf899f 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -24,7 +24,8 @@ public class OfflinePlayer : Player /// /// 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 is engaged again on sight, instead of being forgiven after seconds. + /// 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 . /// private static readonly TimeSpan AggressionMemory = TimeSpan.FromMinutes(5); @@ -265,7 +266,8 @@ internal void RegisterDeathByPlayer(Player killer) /// Arms a pending revenge once the bot respawned, called by the /// 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 attacks the killer on sight, and the revenge gets its time-to-live. + /// AI keeps the killer prioritized (struck only when legal, see ), + /// and the revenge gets its time-to-live. /// internal void ArmRevengeAfterRespawn() { From a98ca74368dee0a9f2154561dabda0dc87b1d0f7 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 9 Jul 2026 08:13:25 +0200 Subject: [PATCH 34/60] Stop bots from risking Souls above +6 on items without luck From +7 on a failed Jewel of Soul resets the item to +0, and at the base 50% success rate that gamble wipes gear more often than not. Now a plain item is only pushed +6 -> +7, where a failure merely drops it to +5 and a Bless recovers that cheaply; only items with luck (+25% success) may be risked further, up to the jewel ceiling of +9. Bless usage is unchanged and ignores luck entirely. --- src/GameLogic/Bots/BotJewelHandler.cs | 34 ++++++++++++++----- .../BotJewelHandlerTest.cs | 23 +++++++++++++ 2 files changed, 48 insertions(+), 9 deletions(-) diff --git a/src/GameLogic/Bots/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs index 4a9a1d402..05455fca0 100644 --- a/src/GameLogic/Bots/BotJewelHandler.cs +++ b/src/GameLogic/Bots/BotJewelHandler.cs @@ -15,10 +15,12 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// Soul or Life through the regular - the same validations, success /// rates and failure penalties as for a human - and puts the piece back on. /// The policy mirrors common player behavior: Bless (always succeeds) pushes the weakest equipped -/// piece towards +6; Soul (50%, +25% with luck, drops the level on failure) is only risked above +6 -/// with a spare in stock; Life (50%, removes the option on failure) is used sparingly on already -/// upgraded gear. Only a couple of jewels are spent per trip, so the upgrades trickle in over many -/// visits like for a player instead of the whole hoard being burned at once. +/// piece towards +6, whether it has luck or not; Soul (50%, +25% with luck; a failure at +6 drops +/// the item to +5, from +7 on it resets it to +0) is only risked with a spare in stock, on plain +/// items only at +6 and up to +9 only on lucky ones; Life (50%, removes the option on failure) is +/// used sparingly on already upgraded gear. Only a couple of jewels are spent per trip, so the +/// upgrades trickle in over many visits like for a player instead of the whole hoard being burned +/// at once. /// internal static class BotJewelHandler { @@ -28,11 +30,22 @@ internal static class BotJewelHandler /// The Jewel of Bless upgrades item levels 0..5 (see BlessJewelConsumeHandlerPlugIn). private const byte BlessMaxTargetLevel = 5; - /// The Jewel of Soul risk window: +6..+8 (a failure above +6 resets the item to +0!). + /// Souls are never spent below +6 - that range is Bless territory (safe and cheap). private const byte SoulMinTargetLevel = 6; - /// The Jewel of Soul upgrades item levels up to 8 (see SoulJewelConsumeHandlerPlugIn). - private const byte SoulMaxTargetLevel = 8; + /// + /// Without luck a Soul is only risked at +6, where a failure merely drops the item to +5 (a Bless + /// restores that): from +7 on, a failed Soul resets the item to +0 + /// (ResetToLevel0WhenFailMinLevel in SoulJewelConsumeHandlerPlugIn), and at the base + /// 50% success rate that gamble wipes gear more often than not. + /// + private const byte SoulMaxTargetLevelPlain = 6; + + /// + /// With luck (+25% success) the Soul may be risked up to +8, so lucky items can reach the jewel + /// ceiling of +9 (MaximumLevel in SoulJewelConsumeHandlerPlugIn). + /// + private const byte SoulMaxTargetLevelLucky = 8; /// Only risk a Soul with at least this many in stock - one failure must not wipe out the reserve. private const int MinSoulStock = 2; @@ -120,9 +133,12 @@ internal static (Item Jewel, Item Target, bool IsLife)? PlanNextUse(Player playe return (blessStock[0], blessTarget, false); } - // 2. Soul - risky: only with a spare in stock; luck (+25% success) makes an item the preferred target. + // 2. Soul - risky: only with a spare in stock, and only where the possible loss is bearable - + // items without luck stop at +6 -> +7 (see SoulMaxTargetLevelPlain), lucky ones may go for +9. if (soulStock.Count >= MinSoulStock - && equipped.Where(i => i.CanLevelBeUpgraded() && i.Level is >= SoulMinTargetLevel and <= SoulMaxTargetLevel) + && equipped.Where(i => i.CanLevelBeUpgraded() + && i.Level >= SoulMinTargetLevel + && i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain)) .OrderByDescending(HasLuck) .ThenBy(i => i.Level) .FirstOrDefault() is { } soulTarget) diff --git a/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs index bf65a4470..59007daf1 100644 --- a/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs @@ -80,6 +80,29 @@ public async ValueTask SoulPrefersLuckyItemAsync() Assert.That(plan!.Value.Target, Is.SameAs(luckyPiece)); } + /// + /// Without luck the Soul risk stops at +6 (a failure from +7 on resets the item to +0), so only + /// lucky items may be pushed further - up to the jewel ceiling of +9. + /// + /// The level of the equipped item. + /// Whether the equipped item has luck. + /// Whether a jewel use is expected. + [TestCase(7, false, false)] + [TestCase(7, true, true)] + [TestCase(8, true, true)] + [TestCase(9, true, false)] + public async ValueTask RisksSoulAbovePlusSixOnlyWithLuckAsync(byte itemLevel, bool withLuck, bool expectsUse) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, itemLevel, withLuck).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfSoul).ConfigureAwait(false); + await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false); + + var plan = BotJewelHandler.PlanNextUse(player, false); + + Assert.That(plan.HasValue, Is.EqualTo(expectsUse)); + } + /// /// Life is the last resort and is planned at most once per trip. /// From 907dbdd1a8aa80fdaf5a4d81a33bbdefac664613 Mon Sep 17 00:00:00 2001 From: nolt Date: Fri, 10 Jul 2026 10:53:42 +0200 Subject: [PATCH 35/60] Add the master (third generation) stage for bots At the game's maximum level a bot now evolves into its master class - the same class assignment the level-400 master quests perform - and is restarted like a relogging player, so the master class's base attributes (master experience rate, points per master level) and the master level stat get mounted; master experience then flows from hunting. Earned master points are invested into the master skill tree through the regular AddMasterPointAction: a started skill is first pushed to the rank-unlock level of 10, then new eligible skills are learned - preferring stat passives and strengtheners of skills the bot actually has - and finally everything is pumped towards its maximum. On reset servers one iron rule applies: a bot only becomes a master once the configured reset limit is exhausted; without a limit it never does, because resetting forever is the endgame there. Master skills are excluded from the free skill learning, and the bot-effective level now counts master levels like the game's own total level. --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 111 +++++++- src/GameLogic/Bots/BotManager.cs | 53 ++++ src/GameLogic/Bots/BotMasterHandler.cs | 244 +++++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 43 ++- src/GameLogic/Bots/BotProgression.cs | 24 ++ src/GameLogic/Bots/BotResetHandler.cs | 7 +- .../Offline/BotMasterHandlerTests.cs | 247 ++++++++++++++++++ 7 files changed, 712 insertions(+), 17 deletions(-) create mode 100644 src/GameLogic/Bots/BotMasterHandler.cs create mode 100644 tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 44293a8d7..090ca1be0 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -6,6 +6,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using Microsoft.Extensions.Logging; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.PlugIns; @@ -42,10 +43,25 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus private readonly BotManager _botManager = new(); + /// + /// Bots whose respawn after the master evolution failed (see ), + /// retried on the following maintenance passes - without this, a transient spawn failure would + /// leave the character offline until a server restart when the presence rotation is disabled. + /// + private readonly System.Collections.Concurrent.ConcurrentQueue<(string Login, byte Slot)> _pendingRespawns = new(); + private DateTime _nextRunUtc = DateTime.UtcNow + StartupDelay; private DateTime _nextMaintenanceUtc = DateTime.UtcNow + StartupDelay + StartupDelay; private DateTime _nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; - private bool _spawned; + + /// + /// 0 = startup not run yet, 1 = startup in progress, 2 = startup done (maintenance mode). + /// The periodic task timer fires every second WITHOUT awaiting the previous invocation, so + /// during the minutes-long generation/spawn further ticks arrive concurrently - they must + /// neither re-enter the startup nor run the maintenance (e.g. the presence rotation) against + /// a half-spawned population. + /// + private int _startupState; /// public BotConfiguration? Configuration { get; set; } @@ -58,13 +74,14 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { - if (this._spawned) + if (this._startupState == 2) { await this.RunMaintenanceAsync(gameContext).ConfigureAwait(false); return; } - if (DateTime.UtcNow < this._nextRunUtc) + if (DateTime.UtcNow < this._nextRunUtc + || Interlocked.CompareExchange(ref this._startupState, 1, 0) != 0) { return; } @@ -72,11 +89,25 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) var configuration = this.Configuration ??= CreateDefaultConfiguration(); if (!configuration.Enabled) { + // Not spawned - re-check on the following ticks, the feature may get enabled later. + Interlocked.Exchange(ref this._startupState, 0); return; } - this._spawned = true; + try + { + await this.SpawnPopulationAsync(gameContext, configuration).ConfigureAwait(false); + } + finally + { + // Like before: the startup runs once, even when parts of it failed (the errors are + // logged); the maintenance pass takes over from here. + Interlocked.Exchange(ref this._startupState, 2); + } + } + private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfiguration configuration) + { var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); using var scope = logger.BeginScope(gameContext); @@ -208,6 +239,9 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); try { + await this.RespawnPendingAsync(gameContext).ConfigureAwait(false); + await this.EvolveDueMastersAsync(gameContext, logger).ConfigureAwait(false); + if (configuration.PresenceRotation) { await this.RotatePresenceAsync(gameContext, configuration, logger).ConfigureAwait(false); @@ -225,6 +259,75 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) } } + /// + /// Evolves bots which reached the game's maximum level into their master class (see + /// for the rules, including the iron rule of reset servers). + /// Runs from the maintenance pass - outside the bot's own AI tick - because the evolved bot is + /// restarted right away (see ), which must not happen + /// from within one of its own timer callbacks. + /// + private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger logger) + { + foreach (var bot in this._botManager.Bots) + { + if (!BotMasterHandler.IsMasterEvolutionDue(bot)) + { + continue; + } + + // Not while it hunts with a human (the restart would desert the group), sits in an NPC + // dialog or lies dead - like a due reset, the evolution simply happens on a later pass. + if (BotPartyHandler.HasHumanCompanion(bot) + || bot.PlayerState.CurrentState != PlayerState.EnteredWorld + || !bot.IsAlive) + { + continue; + } + + // Captured before the restart - the disposed bot loses its account and character. + var loginName = bot.Account?.LoginName; + var characterSlot = bot.SelectedCharacter?.CharacterSlot; + try + { + if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false) + && !await this._botManager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) + && loginName is not null + && characterSlot is { } slot) + { + // The evolution is persisted; only the presence is at risk. RespawnPendingAsync + // drops the entry when the bot is (still or again) online, so a kept-alive old + // instance or a rotation comeback doesn't get doubled. + logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name); + this._pendingRespawns.Enqueue((loginName, slot)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to evolve bot '{Name}' into its master class.", bot.Name); + } + } + } + + /// + /// Retries bringing back bots whose respawn after the master evolution failed. + /// + private async ValueTask RespawnPendingAsync(GameContext gameContext) + { + var count = this._pendingRespawns.Count; + for (var i = 0; i < count && this._pendingRespawns.TryDequeue(out var entry); i++) + { + if (this._botManager.IsActive(entry.Login, entry.Slot)) + { + continue; + } + + if (!await this._botManager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false)) + { + this._pendingRespawns.Enqueue(entry); + } + } + } + private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger) { var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount(); diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index 2b6b99e5e..d0da5feba 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -159,6 +159,59 @@ public async ValueTask StopAllAsync() return name; } + /// + /// Stops the given bot like a regular logout and immediately brings the same character back + /// online - the ghost equivalent of a player relogging. Used after the master evolution: the + /// master class's base attributes (master experience rate, master points per level) and the + /// master level stat are only mounted when the character enters the world, so the class change + /// must be followed by a fresh world entry before master experience can flow. + /// + /// The game context. + /// The bot to restart. + /// true if the bot came back online. + public async ValueTask RestartBotAsync(IGameContext gameContext, BotPlayer bot) + { + // Captured before the stop - the disposed bot loses its account and character. + var loginName = bot.Account?.LoginName; + var characterSlot = bot.SelectedCharacter?.CharacterSlot; + if (loginName is null + || characterSlot is not { } slot + || !this._bots.TryRemove(GetKey(loginName, slot), out var removed)) + { + return false; + } + + if (removed.Party is { } party) + { + try + { + await party.KickMySelfAsync(removed).ConfigureAwait(false); + } + catch (Exception ex) + { + // A failed party goodbye must not skip the stop below - the party cleans up a + // disconnected member itself. + removed.Logger.LogWarning(ex, "Bot '{Login}/{Slot}' couldn't leave its party for the restart.", loginName, slot); + } + } + + try + { + await removed.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + // The old instance may still be (partially) alive - put it back under management and + // don't spawn a second player driving the same character (two persistence contexts + // saving one character would be last-write-wins data loss). Retried on a later pass. + removed.Logger.LogError(ex, "Error while stopping bot '{Login}/{Slot}' for a restart; keeping the old instance.", loginName, slot); + this._bots.TryAdd(GetKey(loginName, slot), removed); + return false; + } + + return await this.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false); + } + /// /// Groups a share of the active bots into small hunting parties of level-wise similar characters, /// like real players do: the party members follow their leader (see the follow logic in diff --git a/src/GameLogic/Bots/BotMasterHandler.cs b/src/GameLogic/Bots/BotMasterHandler.cs new file mode 100644 index 000000000..73bb65d44 --- /dev/null +++ b/src/GameLogic/Bots/BotMasterHandler.cs @@ -0,0 +1,244 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.Character; + +/// +/// Handles the third-generation ("master") stage of a bot's career: the evolution into the master +/// class at the game's maximum level - the same class assignment the level-400 master quests perform +/// for a human player - and the investment of the master points earned per master level, through the +/// regular with all its validations. +/// +/// +/// On servers with the reset feature the evolution follows one iron rule: a bot only becomes a master +/// when no reset can ever follow, i.e. the configured reset limit is exhausted. While resets remain - +/// or when no limit is configured at all, so resetting forever is the endgame - the bot keeps resetting +/// and never masters, like the players of such servers. Without the reset feature it evolves as soon as +/// it reaches the maximum level. +/// The master class's base attributes (master experience rate, master points per level) and the master +/// level stat are only mounted into the attribute system when the character enters the world, so the +/// caller must restart the bot after the evolution (see BotManager.RestartBotAsync) - the ghost +/// equivalent of a player relogging; master experience only flows after that fresh world entry. +/// +internal static class BotMasterHandler +{ + /// + /// A master skill unlocks the next rank of its root (and satisfies "required skill" links) at this + /// level, see . + /// + private const int RankUnlockLevel = 10; + + private static readonly AddMasterPointAction AddPointAction = new(); + + /// + /// Determines whether the bot is due for its master evolution: it has a master class to evolve + /// into, stands at the game's maximum level, and - on reset servers - exhausted the reset limit + /// (see the remarks of for the rationale). + /// + /// The bot player. + /// True, if the bot should evolve into its master class now. + public static bool IsMasterEvolutionDue(Player player) + { + if (player.SelectedCharacter is not { CharacterClass: { } currentClass } + || player.Attributes is not { } attributes + || BotProgression.GetMasterEvolutionTarget(currentClass) is null) + { + return false; + } + + if ((int)attributes[Stats.Level] < player.GameContext.Configuration.MaximumLevel) + { + return false; + } + + if (BotResetHandler.GetResetConfiguration(player.GameContext) is { } resetConfiguration + && (resetConfiguration.ResetLimit is not > 0 + || (int)attributes[Stats.Resets] < resetConfiguration.ResetLimit)) + { + return false; + } + + return true; + } + + /// + /// Evolves the bot into its master class when due. The caller must restart the bot afterwards + /// (see the remarks of ). + /// + /// The bot player. + /// True, if the evolution was performed. + public static async ValueTask TryEvolveAsync(OfflinePlayer player) + { + if (!IsMasterEvolutionDue(player) || player.SelectedCharacter is not { } character) + { + return false; + } + + var masterClass = BotProgression.GetMasterEvolutionTarget(character.CharacterClass!)!; + character.CharacterClass = masterClass; + player.Logger.LogInformation( + "Bot '{Name}' evolved into master class {Class} at level {Level}.", + player.Name, + masterClass.Name, + player.Level); + + try + { + // Persist right away like a performed reset - the following restart reloads the character + // from the database, so the class change must be down there before it. + await player.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after its master evolution; the logout save will retry.", player.Name); + } + + return true; + } + + /// + /// Determines whether the bot is a master with points to invest - the cheap per-tick guard for + /// queueing . + /// + /// The bot player. + /// True, if there are master points to spend. + public static bool HasMasterPointsToSpend(Player player) + => player.SelectedCharacter is { CharacterClass.IsMasterClass: true, MasterLevelUpPoints: > 0 }; + + /// + /// Invests the bot's available master points (earned one per master level) into its master skill + /// tree through the regular . Learning a skill mutates the + /// skill list, so this must run inside the bot's AI tick - queue it via + /// (see the call site in BotNavigator); + /// with no points available it is a cheap no-op. + /// + /// The bot player. + public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player) + { + if (player.SelectedCharacter is not { CharacterClass.IsMasterClass: true } character + || character.MasterLevelUpPoints < 1) + { + return; + } + + while (character.MasterLevelUpPoints > 0 && PickNextMasterSkill(player) is { } skill) + { + var pointsBefore = character.MasterLevelUpPoints; + await AddPointAction.AddMasterPointAsync(player, (ushort)skill.Number).ConfigureAwait(false); + if (character.MasterLevelUpPoints >= pointsBefore) + { + // The action refused - its own checks are authoritative, don't loop on the same pick. + break; + } + + player.Logger.LogInformation( + "Bot '{Name}' invested {Points} master point(s) into '{Skill}'.", + player.Name, + pointsBefore - character.MasterLevelUpPoints, + skill.Name); + } + } + + /// + /// Picks the master skill the bot invests its next point into, or null when nothing is + /// eligible. The policy fills the tree like a player: first push a started skill to the rank-unlock + /// level of 10, then learn a new eligible skill - preferring "useful" ones, i.e. passives boosting a + /// stat or strengtheners of a skill the bot actually has - and finally pump the learned skills + /// towards their maximum. Deterministic, so a bot builds the same tree across sessions. + /// Pure decision logic - exposed for unit tests. + /// + /// The bot player. + internal static Skill? PickNextMasterSkill(Player player) + { + if (player.SelectedCharacter is not { CharacterClass: { } characterClass } character + || player.SkillList is not { } skillList) + { + return null; + } + + var learned = character.LearnedSkills + .Where(l => l.Skill?.MasterDefinition?.Root is not null) + .ToList(); + + if (learned + .Where(l => l.Level < RankUnlockLevel && l.Level < l.Skill!.MasterDefinition!.MaximumLevel) + .OrderBy(l => l.Skill!.MasterDefinition!.Rank) + .ThenBy(l => l.Skill!.Number) + .FirstOrDefault() is { } gate) + { + return gate.Skill; + } + + if (player.GameContext.Configuration.Skills + .Where(s => s.MasterDefinition?.Root is not null + && s.QualifiedCharacters.Contains(characterClass) + && character.LearnedSkills.All(l => l.Skill != s) + && CanLearn(player, s, character.MasterLevelUpPoints)) + .OrderBy(s => IsUsefulPick(s, skillList) ? 0 : 1) + .ThenBy(s => s.MasterDefinition!.Rank) + .ThenBy(s => s.Number) + .FirstOrDefault() is { } newSkill) + { + return newSkill; + } + + return learned + .Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel) + .OrderBy(l => IsUsefulPick(l.Skill!, skillList) ? 0 : 1) + .ThenBy(l => l.Skill!.MasterDefinition!.Rank) + .ThenBy(l => l.Skill!.Number) + .FirstOrDefault()?.Skill; + } + + /// + /// Mirrors the private requisition checks of (minimum points, + /// previous rank of the same root at 10+, required skills), so the picker only proposes skills the + /// action will accept. A mismatch is harmless: the action refuses and the spend loop stops. + /// + private static bool CanLearn(Player player, Skill skill, int availablePoints) + { + var definition = skill.MasterDefinition!; + if (availablePoints < definition.MinimumLevel) + { + return false; + } + + if (definition.Rank > 1 + && !player.SelectedCharacter!.LearnedSkills.Any(l => + l.Skill?.MasterDefinition?.Root is { } root + && root.Id == definition.Root?.Id + && l.Skill.MasterDefinition.Rank == definition.Rank - 1 + && l.Level >= RankUnlockLevel)) + { + return false; + } + + if (definition.RequiredMasterSkills?.Any() == true + && !definition.RequiredMasterSkills.All(s => + player.SelectedCharacter!.LearnedSkills.Any(l => l.Skill == s && l.Level >= RankUnlockLevel) + || (s.MasterDefinition is null && player.SkillList?.ContainsSkill((ushort)s.Number) == true))) + { + return false; + } + + return true; + } + + /// + /// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat, + /// or a strengthener/mastery of a skill the bot actually has in its list. The rest (e.g. weapon + /// strengtheners of weapon types the bot doesn't carry) still gets filled, just last. + /// + private static bool IsUsefulPick(Skill skill, ISkillList skillList) + { + var definition = skill.MasterDefinition!; + return definition.TargetAttribute is not null + || (definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number)); + } +} diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 482bfcfa4..613a198b0 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -312,14 +312,19 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // Make sure the bot always carries healing and mana potions. Without them the offline handlers // have nothing to drink: the bot dies to sustained damage, and casters degrade to weak melee // once their mana runs dry. This also tops up already-generated bots at runtime. - await this.EnsurePotionsAsync().ConfigureAwait(false); + // Queued into the MuHelper tick: an ammunition refill lands in an equipped hand slot, which + // mounts item power-ups into the attribute system - and any attribute mutation from this + // (separate) timer racing the combat handler's recalculations can corrupt the attribute + // graph permanently (see PendingBotActions). + this._player.PendingBotActions.Enqueue(() => this.EnsurePotionsAsync()); // Periodically evaluate looted gear and equip upgrades (drops the replaced piece), so the bot's - // equipment progresses over its lifetime like a real player's. + // equipment progresses over its lifetime like a real player's. Queued for the same reason: + // equipping mounts/unmounts item power-ups. if (DateTime.UtcNow >= this._nextEquipCheckUtc) { this._nextEquipCheckUtc = DateTime.UtcNow + EquipCheckInterval; - await BotEquipmentHandler.TryEquipUpgradesAsync(this._player).ConfigureAwait(false); + this._player.PendingBotActions.Enqueue(() => BotEquipmentHandler.TryEquipUpgradesAsync(this._player)); } // Keep the combat centre on the bot's current position, so the combat handler always engages @@ -379,6 +384,15 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // A master bot invests freshly earned master points (one per master level). Queued into the + // MuHelper tick like the level-up progression - learning a skill mutates the skill list, which + // must never happen while the combat handler enumerates it (see PendingBotActions). The master + // evolution itself is handled by the feature plugin's maintenance pass, because it restarts the bot. + if (BotMasterHandler.HasMasterPointsToSpend(this._player)) + { + this._player.PendingBotActions.Enqueue(() => BotMasterHandler.TrySpendMasterPointsAsync(this._player)); + } + // A shopping trip (selling junk loot, restocking potions with Zen) runs before everything else, // so it completes even for party members - the follow logic below would otherwise pull them // back to the leader mid-trade. @@ -517,8 +531,10 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) if (await BotShoppingHandler.TryTradeAsync(this._player, map, target).ConfigureAwait(false)) { // Still standing safely at the merchant with the dialog closed - the player-like moment - // to spend a few looted jewels on the own gear (see BotJewelHandler). - await BotJewelHandler.TryUpgradeGearAsync(this._player).ConfigureAwait(false); + // to spend a few looted jewels on the own gear (see BotJewelHandler). Queued into the + // MuHelper tick: applying a jewel takes gear off and back on, and such attribute + // mutations must not race the combat handler (see PendingBotActions). + this._player.PendingBotActions.Enqueue(() => BotJewelHandler.TryUpgradeGearAsync(this._player)); } this._shoppingTarget = null; @@ -726,16 +742,21 @@ private async ValueTask TryResetCharacterAsync() this._resetDueAtUtc = null; var payCosts = this._player.GameContext.FeaturePlugIns.GetPlugIn()?.Configuration?.BotsPayResetCosts == true; - if (!await BotResetHandler.TryResetAsync(this._player, resetConfiguration, payCosts).ConfigureAwait(false)) + + // Queued into the MuHelper tick: the reset writes the level/reset/stat attributes, and such + // mutations must not race the combat handler's recalculations (see PendingBotActions). The + // queued call re-validates the eligibility itself; if it refuses (e.g. unaffordable costs + // when BotsPayResetCosts is enabled), the check above re-schedules a fresh grace delay. + var player = this._player; + player.PendingBotActions.Enqueue(async () => { - // E.g. unaffordable costs when BotsPayResetCosts is enabled - the eligibility check above - // re-schedules another attempt on a fresh delay next tick. - return false; - } + await BotResetHandler.TryResetAsync(player, resetConfiguration, payCosts).ConfigureAwait(false); + }); // Start the next cycle fresh: no stale destination or route, and no warp cooldown - a freshly // reset player heads straight back out, and the point pool (queued by the reset handler into - // the bot's AI tick) is invested before the next evaluation gets this far. + // the bot's AI tick) is invested before the next evaluation gets this far. Cleared + // optimistically - if the queued reset refuses, the bot merely re-picks its hunting ground. this._hasDestination = false; this._travelPath = null; this._emptyGroundSince = null; diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs index de75b8668..e4948abbd 100644 --- a/src/GameLogic/Bots/BotProgression.cs +++ b/src/GameLogic/Bots/BotProgression.cs @@ -79,6 +79,23 @@ internal static class BotProgression : null; } + /// + /// Gets the master class the character evolves into at the game's maximum level (the + /// third-generation evolution the level-400 master quests perform), or null when the current class + /// has none. Unlike this applies to all classes: the + /// second-generation classes evolve into their masters (Blade Knight -> Blade Master, ...), and + /// Magic Gladiator, Dark Lord and Rage Fighter - which have no second generation - evolve directly + /// (-> Duel Master, Lord Emperor, Fist Master). When and whether a bot takes this step is decided + /// by . + /// + /// The character class. + public static CharacterClass? GetMasterEvolutionTarget(CharacterClass characterClass) + { + return characterClass is { IsMasterClass: false, NextGenerationClass: { IsMasterClass: true } masterClass } + ? masterClass + : null; + } + /// /// How a bot invests its stat points, per class and per bot, in one of two meta profiles chosen /// by the server type (see at the call sites): @@ -270,6 +287,13 @@ public static int GetVitalityTarget(string characterName) /// The skill to check. public static bool IsBotLearnableSkill(Skill skill) { + if (skill.MasterDefinition is not null) + { + // Master skills are never learned for free - they cost the master points earned per master + // level and go through the regular action (see BotMasterHandler), like for a human player. + return false; + } + if (skill.AttackDamage > 0 && skill.SkillType is SkillType.DirectHit or SkillType.AreaSkillAutomaticHits diff --git a/src/GameLogic/Bots/BotResetHandler.cs b/src/GameLogic/Bots/BotResetHandler.cs index f1ef2b484..915e22cb9 100644 --- a/src/GameLogic/Bots/BotResetHandler.cs +++ b/src/GameLogic/Bots/BotResetHandler.cs @@ -39,13 +39,16 @@ internal static class BotResetHandler /// fights with the accumulated power of all its resets, so the /// plain level would misjudge it everywhere (target safety, map choice, party matching). Each /// reset counts as the level span it took (). - /// Without the reset feature this is simply the character level. + /// Master levels count on top (like the game's own total level), so an evolved master keeps + /// being judged stronger than a plain level-capped character. + /// Without the reset feature this is the character level plus the master level. /// /// The player. /// The effective level. public static int GetEffectiveLevel(Player player) { - var level = (int)(player.Attributes?[Stats.Level] ?? 1); + var level = (int)(player.Attributes?[Stats.Level] ?? 1) + + (int)(player.Attributes?[Stats.MasterLevel] ?? 0f); if (GetResetConfiguration(player.GameContext) is not { } configuration) { return level; diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs new file mode 100644 index 000000000..30f97ca5e --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs @@ -0,0 +1,247 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests.Offline; + +using Moq; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Resets; + +/// +/// Tests for : when a bot evolves into its master class (including the +/// iron rule of reset servers) and which master skill it invests its points into. The point investment +/// itself goes through the regular , +/// whose rules are covered by . +/// +[TestFixture] +public class BotMasterHandlerTests +{ + private IGameContext _gameContext = null!; + + /// + /// Sets up a fresh game context with the usual maximum level before each test. + /// + [SetUp] + public void SetUp() + { + this._gameContext = GameContextTestHelper.CreateGameContext(); + this._gameContext.Configuration.MaximumLevel = 400; + } + + /// + /// Without the reset feature the evolution is due exactly at the game's maximum level. + /// + /// The character level. + /// Whether the evolution is expected to be due. + [TestCase(399, false)] + [TestCase(400, true)] + public async ValueTask EvolutionIsDueAtMaximumLevelAsync(int level, bool expectsDue) + { + var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false); + player.Attributes![Stats.Level] = level; + + Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue)); + } + + /// + /// A class which is already a master (or has no master target) never evolves again. + /// + [Test] + public async ValueTask NoEvolutionWithoutMasterTargetAsync() + { + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); + player.Attributes![Stats.Level] = 400; + + Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.False); + } + + /// + /// The iron rule of reset servers: the evolution is only due once the reset limit is exhausted, + /// and with no limit configured (resetting forever is the endgame) it is never due at all. + /// Uses the plain test context, where the added feature plugin is the effective one (see the + /// remarks at ). + /// + /// The configured reset limit; 0 means no limit. + /// The bot's performed resets. + /// Whether the evolution is expected to be due. + [TestCase(3, 2, false)] + [TestCase(3, 3, true)] + [TestCase(0, 50, false)] + public async ValueTask EvolutionOnResetServersOnlyAfterLastResetAsync(int resetLimit, int resets, bool expectsDue) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + player.GameContext.Configuration.MaximumLevel = 400; + GiveMasterTarget(player); + player.GameContext.FeaturePlugIns.AddPlugIn( + new ResetFeaturePlugIn { Configuration = new ResetConfiguration { RequiredLevel = 400, ResetLimit = resetLimit } }, + true); + player.Attributes![Stats.Level] = 400; + player.Attributes[Stats.Resets] = resets; + + Assert.That(BotMasterHandler.IsMasterEvolutionDue(player), Is.EqualTo(expectsDue)); + } + + /// + /// A due evolution assigns the master class - the same assignment the master quest performs. + /// + [Test] + public async ValueTask EvolutionAssignsMasterClassAsync() + { + var player = await this.CreatePlayerWithMasterTargetAsync().ConfigureAwait(false); + var masterClass = player.SelectedCharacter!.CharacterClass!.NextGenerationClass!; + player.Attributes![Stats.Level] = 400; + + var evolved = await BotMasterHandler.TryEvolveAsync(player).ConfigureAwait(false); + + Assert.That(evolved, Is.True); + Assert.That(player.SelectedCharacter!.CharacterClass, Is.SameAs(masterClass)); + } + + /// + /// The point spending loop learns the picked skill through the regular action and invests all + /// available points. + /// + [Test] + public async ValueTask SpendsPointsThroughRegularActionAsync() + { + // The plain test context is required here - its configuration accepts the mocked skills. + var contextDonor = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(contextDonor.GameContext).ConfigureAwait(false); + player.SelectedCharacter!.CharacterClass!.IsMasterClass = true; + var skill = this.CreateMasterSkill(1, rank: 1, player.SelectedCharacter.CharacterClass); + player.GameContext.Configuration.Skills.Add(skill); + player.SelectedCharacter.MasterLevelUpPoints = 3; + + await BotMasterHandler.TrySpendMasterPointsAsync(player).ConfigureAwait(false); + + Assert.That(player.SelectedCharacter.MasterLevelUpPoints, Is.Zero); + var learned = player.SelectedCharacter.LearnedSkills.FirstOrDefault(l => l.Skill == skill); + Assert.That(learned, Is.Not.Null); + Assert.That(learned!.Level, Is.EqualTo(3)); + } + + /// + /// A started skill is pushed to the rank-unlock level of 10 before anything new is learned. + /// + [Test] + public async ValueTask FinishesStartedSkillBeforeLearningNewAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var characterClass = player.SelectedCharacter!.CharacterClass!; + var startedSkill = this.CreateMasterSkill(1, rank: 1, characterClass); + var otherSkill = this.CreateMasterSkill(2, rank: 1, characterClass); + player.GameContext.Configuration.Skills.Add(startedSkill); + player.GameContext.Configuration.Skills.Add(otherSkill); + player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = startedSkill, Level = 5 }); + player.SelectedCharacter.MasterLevelUpPoints = 1; + + Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(startedSkill)); + } + + /// + /// A skill of the next rank only becomes eligible once a skill of the previous rank of the same + /// root reached level 10; a next-rank skill of another root stays out of reach and the points go + /// into pumping the finished skill instead. + /// + /// Whether the rank-2 skill shares the root of the learned rank-1 skill. + [TestCase(true)] + [TestCase(false)] + public async ValueTask RespectsRankGatePerRootAsync(bool sameRoot) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var characterClass = player.SelectedCharacter!.CharacterClass!; + var rank1 = this.CreateMasterSkill(1, rank: 1, characterClass, rootId: 1); + var rank2 = this.CreateMasterSkill(2, rank: 2, characterClass, rootId: sameRoot ? (byte)1 : (byte)2); + player.GameContext.Configuration.Skills.Add(rank1); + player.GameContext.Configuration.Skills.Add(rank2); + player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = rank1, Level = 10 }); + player.SelectedCharacter.MasterLevelUpPoints = 1; + + var pick = BotMasterHandler.PickNextMasterSkill(player); + + Assert.That(pick, Is.SameAs(sameRoot ? rank2 : rank1)); + } + + /// + /// Among equally reachable new skills, a "useful" one (here: a passive boosting a stat) is + /// preferred even when a useless one comes first by number. + /// + [Test] + public async ValueTask PrefersUsefulSkillAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var characterClass = player.SelectedCharacter!.CharacterClass!; + var uselessSkill = this.CreateMasterSkill(1, rank: 1, characterClass); + var passiveSkill = this.CreateMasterSkill(2, rank: 1, characterClass); + passiveSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth; + player.GameContext.Configuration.Skills.Add(uselessSkill); + player.GameContext.Configuration.Skills.Add(passiveSkill); + player.SelectedCharacter.MasterLevelUpPoints = 1; + + Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(passiveSkill)); + } + + /// + /// With everything learned at its maximum nothing is picked - the loop stops. + /// + [Test] + public async ValueTask PicksNothingWhenTreeIsFullAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var characterClass = player.SelectedCharacter!.CharacterClass!; + var skill = this.CreateMasterSkill(1, rank: 1, characterClass); + player.GameContext.Configuration.Skills.Add(skill); + player.SelectedCharacter.LearnedSkills.Add(new SkillEntry { Skill = skill, Level = 20 }); + player.SelectedCharacter.MasterLevelUpPoints = 5; + + Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.Null); + } + + /// + /// Creates an offline test player whose class has a master class as next generation. + /// + private async ValueTask CreatePlayerWithMasterTargetAsync() + { + var player = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(this._gameContext).ConfigureAwait(false); + GiveMasterTarget(player); + return player; + } + + /// + /// Gives the player's character class a master class as next generation. + /// + private static void GiveMasterTarget(Player player) + { + var masterClass = new CharacterClass + { + Name = "Test Master", + IsMasterClass = true, + }; + Mock.Get(player.SelectedCharacter!.CharacterClass!) + .Setup(c => c.NextGenerationClass) + .Returns(masterClass); + } + + private Skill CreateMasterSkill(short number, byte rank, CharacterClass qualifiedClass, byte rootId = 1) + { + var masterDefinition = new Mock(); + masterDefinition.SetupAllProperties(); + masterDefinition.Object.Rank = rank; + masterDefinition.Object.MaximumLevel = 20; + masterDefinition.Object.MinimumLevel = 1; + masterDefinition.Object.Root = new MasterSkillRoot { Id = new Guid(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, rootId) }; + masterDefinition.Setup(m => m.RequiredMasterSkills).Returns(new List()); + + var skill = new Mock(); + skill.SetupAllProperties(); + skill.Object.Number = number; + skill.Setup(s => s.QualifiedCharacters).Returns(new List { qualifiedClass }); + skill.Object.MasterDefinition = masterDefinition.Object; + return skill.Object; + } +} From 40f09c142418565eb8b535eb4e3b121fc9519b41 Mon Sep 17 00:00:00 2001 From: nolt Date: Sat, 11 Jul 2026 15:57:37 +0200 Subject: [PATCH 36/60] Add the missing Designer file for the AddAccountIsBot migration Generated with 'dotnet ef migrations add' as requested in the review; the resulting migration and model snapshot came out identical to the hand-written ones. The DbContext/Migration attributes move from the migration class to the generated Designer file, where they belong. --- ...20260627120000_AddAccountIsBot.Designer.cs | 5243 +++++++++++++++++ .../20260627120000_AddAccountIsBot.cs | 4 - 2 files changed, 5243 insertions(+), 4 deletions(-) create mode 100644 src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs diff --git a/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs new file mode 100644 index 000000000..70d2b2735 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.Designer.cs @@ -0,0 +1,5243 @@ +// +using System; +using MUnique.OpenMU.Persistence.EntityFramework; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations +{ + [DbContext(typeof(EntityDataContext))] + [Migration("20260627120000_AddAccountIsBot")] + partial class AddAccountIsBot + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatBanUntil") + .HasColumnType("timestamp with time zone"); + + b.Property("EMail") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsBot") + .HasColumnType("boolean"); + + b.Property("IsTemplate") + .HasColumnType("boolean"); + + b.Property("IsVaultExtended") + .HasColumnType("boolean"); + + b.Property("LanguageIsoCode") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(3) + .HasColumnType("character varying(3)") + .HasDefaultValue("en"); + + b.Property("LoginName") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("RegistrationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("SecurityCode") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("TimeZone") + .HasColumnType("smallint"); + + b.Property("VaultId") + .HasColumnType("uuid"); + + b.Property("VaultPassword") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("LoginName") + .IsUnique(); + + b.HasIndex("VaultId") + .IsUnique(); + + b.ToTable("Account", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("AccountId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AccountCharacterClass", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("FullAncientSetEquipped") + .HasColumnType("boolean"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("AppearanceData", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DelayBetweenHits") + .HasColumnType("interval"); + + b.Property("DelayPerOneDistance") + .HasColumnType("interval"); + + b.Property("EffectRange") + .HasColumnType("integer"); + + b.Property("FrustumDistance") + .HasColumnType("real"); + + b.Property("FrustumEndWidth") + .HasColumnType("real"); + + b.Property("FrustumStartWidth") + .HasColumnType("real"); + + b.Property("HitChancePerDistanceMultiplier") + .HasColumnType("real"); + + b.Property("MaximumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MaximumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerAttack") + .HasColumnType("integer"); + + b.Property("MinimumNumberOfHitsPerTarget") + .HasColumnType("integer"); + + b.Property("ProjectileCount") + .HasColumnType("integer"); + + b.Property("TargetAreaDiameter") + .HasColumnType("real"); + + b.Property("UseDeferredHits") + .HasColumnType("boolean"); + + b.Property("UseFrustumFilter") + .HasColumnType("boolean"); + + b.Property("UseTargetAreaFilter") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("AreaSkillSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Designation") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("AttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InputAttributeId") + .HasColumnType("uuid"); + + b.Property("InputOperand") + .HasColumnType("real"); + + b.Property("InputOperator") + .HasColumnType("integer"); + + b.Property("OperandAttributeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionValueId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("InputAttributeId"); + + b.HasIndex("OperandAttributeId"); + + b.HasIndex("PowerUpDefinitionValueId"); + + b.HasIndex("SkillId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("AttributeRelationship", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumValue") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("SkillId1") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SkillId"); + + b.HasIndex("SkillId1"); + + b.ToTable("AttributeRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GroundId") + .HasColumnType("uuid"); + + b.Property("LeftGoalId") + .HasColumnType("uuid"); + + b.Property("LeftTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("LeftTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("RightGoalId") + .HasColumnType("uuid"); + + b.Property("RightTeamSpawnPointX") + .HasColumnType("smallint"); + + b.Property("RightTeamSpawnPointY") + .HasColumnType("smallint"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GroundId") + .IsUnique(); + + b.HasIndex("LeftGoalId") + .IsUnique(); + + b.HasIndex("RightGoalId") + .IsUnique(); + + b.ToTable("BattleZoneDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("CharacterSlot") + .HasColumnType("smallint"); + + b.Property("CharacterStatus") + .HasColumnType("integer"); + + b.Property("CreateDate") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentMapId") + .HasColumnType("uuid"); + + b.Property("Experience") + .HasColumnType("bigint"); + + b.Property("InventoryExtensions") + .HasColumnType("integer"); + + b.Property("InventoryId") + .HasColumnType("uuid"); + + b.Property("IsStoreOpened") + .HasColumnType("boolean"); + + b.Property("KeyConfiguration") + .HasColumnType("bytea"); + + b.Property("LevelUpPoints") + .HasColumnType("integer"); + + b.Property("MasterExperience") + .HasColumnType("bigint"); + + b.Property("MasterLevelUpPoints") + .HasColumnType("integer"); + + b.Property("MuHelperConfiguration") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("PlayerKillCount") + .HasColumnType("integer"); + + b.Property("Pose") + .HasColumnType("smallint"); + + b.Property("PositionX") + .HasColumnType("smallint"); + + b.Property("PositionY") + .HasColumnType("smallint"); + + b.Property("State") + .HasColumnType("integer"); + + b.Property("StateRemainingSeconds") + .HasColumnType("integer"); + + b.Property("StoreName") + .HasColumnType("text"); + + b.Property("UsedFruitPoints") + .HasColumnType("integer"); + + b.Property("UsedNegFruitPoints") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("CurrentMapId"); + + b.HasIndex("InventoryId") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Character", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CanGetCreated") + .HasColumnType("boolean"); + + b.Property("ComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("CreationAllowedFlag") + .HasColumnType("smallint"); + + b.Property("FruitCalculation") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("HomeMapId") + .HasColumnType("uuid"); + + b.Property("IsMasterClass") + .HasColumnType("boolean"); + + b.Property("LevelRequirementByCreation") + .HasColumnType("smallint"); + + b.Property("LevelWarpRequirementReductionPercent") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NextGenerationClassId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ComboDefinitionId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("HomeMapId"); + + b.HasIndex("NextGenerationClassId"); + + b.ToTable("CharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("CharacterId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("CharacterDropItemGroup", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ActiveQuestId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("ClientActionPerformed") + .HasColumnType("boolean"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("LastFinishedQuestId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ActiveQuestId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("LastFinishedQuestId"); + + b.ToTable("CharacterQuestState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ClientTimeout") + .HasColumnType("interval"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("MaximumConnections") + .HasColumnType("integer"); + + b.Property("RoomCleanUpInterval") + .HasColumnType("interval"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("ChatServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ChatServerDefinitionId"); + + b.HasIndex("ClientId"); + + b.ToTable("ChatServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionCombinationBonusId") + .HasColumnType("uuid"); + + b.Property("MinimumCount") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionCombinationBonusId"); + + b.HasIndex("OptionTypeId"); + + b.ToTable("CombinationBonusRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstalledAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdateState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CurrentInstalledVersion") + .HasColumnType("integer"); + + b.Property("InitializationKey") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("ConfigurationUpdateState", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CheckMaxConnectionsPerAddress") + .HasColumnType("boolean"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("ClientListenerPort") + .HasColumnType("integer"); + + b.Property("CurrentPatchVersion") + .HasColumnType("bytea"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DisconnectOnUnknownPacket") + .HasColumnType("boolean"); + + b.Property("ListenerBacklog") + .HasColumnType("integer"); + + b.Property("MaxConnections") + .HasColumnType("integer"); + + b.Property("MaxConnectionsPerAddress") + .HasColumnType("integer"); + + b.Property("MaxFtpRequests") + .HasColumnType("integer"); + + b.Property("MaxIpRequests") + .HasColumnType("integer"); + + b.Property("MaxServerListRequests") + .HasColumnType("integer"); + + b.Property("MaximumReceiveSize") + .HasColumnType("smallint"); + + b.Property("PatchAddress") + .IsRequired() + .HasColumnType("text"); + + b.Property("ServerId") + .HasColumnType("smallint"); + + b.Property("Timeout") + .HasColumnType("interval"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.ToTable("ConnectServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("CharacterClassId"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ConstValueAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MonsterId"); + + b.ToTable("DropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("DropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("DropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("FirstPlayerGateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("smallint"); + + b.Property("SecondPlayerGateId") + .HasColumnType("uuid"); + + b.Property("SpectatorsGateId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DuelConfigurationId"); + + b.HasIndex("FirstPlayerGateId"); + + b.HasIndex("SecondPlayerGateId"); + + b.HasIndex("SpectatorsGateId"); + + b.ToTable("DuelArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("ExitId") + .HasColumnType("uuid"); + + b.Property("MaximumScore") + .HasColumnType("integer"); + + b.Property("MaximumSpectatorsPerDuelRoom") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ExitId"); + + b.ToTable("DuelConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelRequirement") + .HasColumnType("smallint"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("TargetGateId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("TargetGateId"); + + b.ToTable("EnterGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("IsSpawnGate") + .HasColumnType("boolean"); + + b.Property("MapId") + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MapId"); + + b.ToTable("ExitGate", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Friend", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Accepted") + .HasColumnType("boolean"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("RequestOpen") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasAlternateKey("CharacterId", "FriendId"); + + b.ToTable("Friend", "friend"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Episode") + .HasColumnType("smallint"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("Season") + .HasColumnType("smallint"); + + b.Property("Serial") + .HasColumnType("bytea"); + + b.Property("Version") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.ToTable("GameClientDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillHitsPlayer") + .HasColumnType("boolean"); + + b.Property("CharacterNameRegex") + .HasColumnType("text"); + + b.Property("ClampMoneyOnPickup") + .HasColumnType("boolean"); + + b.Property("DamagePerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("DamagePerOnePetDurability") + .HasColumnType("double precision"); + + b.Property("DuelConfigurationId") + .HasColumnType("uuid"); + + b.Property("ExcellentItemDropLevelDelta") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)25); + + b.Property("ExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("HitsPerOneItemDurability") + .HasColumnType("double precision"); + + b.Property("InfoRange") + .HasColumnType("smallint"); + + b.Property("ItemDropDuration") + .ValueGeneratedOnAdd() + .HasColumnType("interval") + .HasDefaultValue(new TimeSpan(0, 0, 1, 0, 0)); + + b.Property("LetterSendPrice") + .HasColumnType("integer"); + + b.Property("MasterExperienceFormula") + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("(505 * level * level * level) + (35278500 * level) + (228045 * level * level)"); + + b.Property("MasterExperienceRate") + .HasColumnType("real"); + + b.Property("MaximumCharactersPerAccount") + .HasColumnType("smallint"); + + b.Property("MaximumInventoryMoney") + .HasColumnType("integer"); + + b.Property("MaximumItemOptionLevelDrop") + .HasColumnType("smallint"); + + b.Property("MaximumLetters") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMasterLevel") + .HasColumnType("smallint"); + + b.Property("MaximumPartySize") + .HasColumnType("smallint"); + + b.Property("MaximumPasswordLength") + .HasColumnType("integer"); + + b.Property("MaximumVaultMoney") + .HasColumnType("integer"); + + b.Property("MinimumMonsterLevelForMasterExperience") + .HasColumnType("smallint"); + + b.Property("PreventExperienceOverflow") + .HasColumnType("boolean"); + + b.Property("RecoveryInterval") + .HasColumnType("integer"); + + b.Property("ShouldDropMoney") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("DuelConfigurationId") + .IsUnique(); + + b.ToTable("GameConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BattleZoneId") + .HasColumnType("uuid"); + + b.Property("Discriminator") + .HasColumnType("integer"); + + b.Property("ExpMultiplier") + .HasColumnType("double precision"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SafezoneMapId") + .HasColumnType("uuid"); + + b.Property("TerrainData") + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("BattleZoneId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("SafezoneMapId"); + + b.ToTable("GameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("GameMapDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("GameMapDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumPlayers") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("GameServerConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.Property("GameServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("GameServerConfigurationId", "GameMapDefinitionId"); + + b.HasIndex("GameMapDefinitionId"); + + b.ToTable("GameServerConfigurationGameMapDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExperienceRate") + .HasColumnType("real"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("PvpEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("ServerConfigurationId") + .HasColumnType("uuid"); + + b.Property("ServerID") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ServerConfigurationId"); + + b.ToTable("GameServerDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlternativePublishedPort") + .HasColumnType("integer"); + + b.Property("ClientId") + .HasColumnType("uuid"); + + b.Property("GameServerDefinitionId") + .HasColumnType("uuid"); + + b.Property("NetworkPort") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ClientId"); + + b.HasIndex("GameServerDefinitionId"); + + b.ToTable("GameServerEndpoint", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllianceGuildId") + .HasColumnType("uuid"); + + b.Property("HostilityId") + .HasColumnType("uuid"); + + b.Property("Logo") + .HasColumnType("bytea"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("Notice") + .HasColumnType("text"); + + b.Property("Score") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AllianceGuildId"); + + b.HasIndex("HostilityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Guild", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("GuildId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GuildId"); + + b.ToTable("GuildMember", "guild"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.Property("LevelType") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.Property("Weight") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("IncreasableItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Durability") + .HasColumnType("double precision"); + + b.Property("HasSkill") + .HasColumnType("boolean"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("ItemStorageId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.Property("PetExperience") + .HasColumnType("integer"); + + b.Property("SocketCount") + .HasColumnType("integer"); + + b.Property("StorePrice") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DefinitionId"); + + b.HasIndex("ItemStorageId"); + + b.ToTable("Item", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppearanceDataId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSlot") + .HasColumnType("smallint"); + + b.Property("Level") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AppearanceDataId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("ItemAppearance", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.Property("ItemAppearanceId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemAppearanceId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemAppearanceItemOptionType", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("BonusPerLevelTableId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusPerLevelTableId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("ItemBasePowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemCraftingHandlerClassName") + .IsRequired() + .HasColumnType("text"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId") + .IsUnique(); + + b.ToTable("ItemCrafting", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddPercentage") + .HasColumnType("smallint"); + + b.Property("FailResult") + .HasColumnType("integer"); + + b.Property("MaximumAmount") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MinimumAmount") + .HasColumnType("smallint"); + + b.Property("MinimumItemLevel") + .HasColumnType("smallint"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.Property("SuccessResult") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingRequiredItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemCraftingRequiredItemItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.Property("ItemCraftingRequiredItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionTypeId") + .HasColumnType("uuid"); + + b.HasKey("ItemCraftingRequiredItemId", "ItemOptionTypeId"); + + b.HasIndex("ItemOptionTypeId"); + + b.ToTable("ItemCraftingRequiredItemItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddLevel") + .HasColumnType("smallint"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("RandomMaximumLevel") + .HasColumnType("smallint"); + + b.Property("RandomMinimumLevel") + .HasColumnType("smallint"); + + b.Property("Reference") + .HasColumnType("smallint"); + + b.Property("SimpleCraftingSettingsId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("SimpleCraftingSettingsId"); + + b.ToTable("ItemCraftingResultItem", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumeEffectId") + .HasColumnType("uuid"); + + b.Property("DropLevel") + .HasColumnType("smallint"); + + b.Property("DropsFromMonsters") + .HasColumnType("boolean"); + + b.Property("Durability") + .HasColumnType("smallint"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("Height") + .HasColumnType("smallint"); + + b.Property("IsAmmunition") + .HasColumnType("boolean"); + + b.Property("IsBoundToCharacter") + .HasColumnType("boolean"); + + b.Property("ItemSlotId") + .HasColumnType("uuid"); + + b.Property("MaximumDropLevel") + .HasColumnType("smallint"); + + b.Property("MaximumItemLevel") + .HasColumnType("smallint"); + + b.Property("MaximumSockets") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PetExperienceFormula") + .HasColumnType("text"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("StorageLimitPerCharacter") + .HasColumnType("integer"); + + b.Property("Value") + .HasColumnType("integer"); + + b.Property("Width") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ConsumeEffectId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("ItemSlotId"); + + b.HasIndex("SkillId"); + + b.ToTable("ItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("ItemDefinitionCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemOptionDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemOptionDefinitionId"); + + b.HasIndex("ItemOptionDefinitionId"); + + b.ToTable("ItemDefinitionItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("ItemDefinitionId", "ItemSetGroupId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemDefinitionItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Chance") + .HasColumnType("double precision"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("DropEffect") + .HasColumnType("integer"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemLevel") + .HasColumnType("smallint"); + + b.Property("ItemType") + .HasColumnType("integer"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MaximumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumMonsterLevel") + .HasColumnType("smallint"); + + b.Property("MoneyAmount") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("RequiredCharacterLevel") + .HasColumnType("smallint"); + + b.Property("SourceItemLevel") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("MonsterId"); + + b.ToTable("ItemDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.Property("ItemDropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("ItemDropItemGroupId", "ItemDefinitionId"); + + b.HasIndex("ItemDefinitionId"); + + b.ToTable("ItemDropItemGroupItemDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOfItemSetId") + .HasColumnType("uuid"); + + b.HasKey("ItemId", "ItemOfItemSetId"); + + b.HasIndex("ItemOfItemSetId"); + + b.ToTable("ItemItemOfItemSet", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemLevelBonusTable", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AncientSetDiscriminator") + .HasColumnType("integer"); + + b.Property("BonusOptionId") + .HasColumnType("uuid"); + + b.Property("ItemDefinitionId") + .HasColumnType("uuid"); + + b.Property("ItemSetGroupId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BonusOptionId"); + + b.HasIndex("ItemDefinitionId"); + + b.HasIndex("ItemSetGroupId"); + + b.ToTable("ItemOfItemSet", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.Property("OptionTypeId") + .HasColumnType("uuid"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("SubOptionType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("OptionTypeId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOption", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AppliesMultipleTimes") + .HasColumnType("boolean"); + + b.Property("BonusId") + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("BonusId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionCombinationBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddChance") + .HasColumnType("real"); + + b.Property("AddsRandomly") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MaximumOptionsPerItem") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("ItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemId"); + + b.HasIndex("ItemOptionId"); + + b.ToTable("ItemOptionLink", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IncreasableItemOptionId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("PowerUpDefinitionId") + .HasColumnType("uuid"); + + b.Property("RequiredItemLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("IncreasableItemOptionId"); + + b.HasIndex("PowerUpDefinitionId") + .IsUnique(); + + b.ToTable("ItemOptionOfLevel", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsVisible") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemOptionType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AlwaysApplies") + .HasColumnType("boolean"); + + b.Property("CountDistinct") + .HasColumnType("boolean"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MinimumItemCount") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("OptionsId") + .HasColumnType("uuid"); + + b.Property("SetLevel") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("OptionsId"); + + b.ToTable("ItemSetGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("RawItemSlots") + .HasColumnType("text") + .HasColumnName("ItemSlots") + .HasJsonPropertyName("itemSlots"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("ItemSlotType", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Money") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ItemStorage", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("MixedJewelId") + .HasColumnType("uuid"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("SingleJewelId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MixedJewelId"); + + b.HasIndex("SingleJewelId"); + + b.ToTable("JewelMix", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Animation") + .HasColumnType("smallint"); + + b.Property("HeaderId") + .HasColumnType("uuid"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rotation") + .HasColumnType("smallint"); + + b.Property("SenderAppearanceId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("HeaderId"); + + b.HasIndex("SenderAppearanceId") + .IsUnique(); + + b.ToTable("LetterBody", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("LetterDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReadFlag") + .HasColumnType("boolean"); + + b.Property("ReceiverId") + .HasColumnType("uuid"); + + b.Property("SenderName") + .HasColumnType("text"); + + b.Property("Subject") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReceiverId"); + + b.ToTable("LetterHeader", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AdditionalValue") + .HasColumnType("real"); + + b.Property("ItemLevelBonusTableId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemLevelBonusTableId"); + + b.ToTable("LevelBonus", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChanceId") + .HasColumnType("uuid"); + + b.Property("ChancePvpId") + .HasColumnType("uuid"); + + b.Property("DurationDependsOnTargetLevel") + .HasColumnType("boolean"); + + b.Property("DurationId") + .HasColumnType("uuid"); + + b.Property("DurationPvpId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("InformObservers") + .HasColumnType("boolean"); + + b.Property("MonsterTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("PlayerTargetLevelDivisor") + .HasColumnType("real"); + + b.Property("SendDuration") + .HasColumnType("boolean"); + + b.Property("StopByDeath") + .HasColumnType("boolean"); + + b.Property("SubType") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("ChanceId") + .IsUnique(); + + b.HasIndex("ChancePvpId") + .IsUnique(); + + b.HasIndex("DurationId") + .IsUnique(); + + b.HasIndex("DurationPvpId") + .IsUnique(); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MagicEffectDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Aggregation") + .HasColumnType("integer"); + + b.Property("DisplayValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExtendsDuration") + .HasColumnType("boolean"); + + b.Property("MaximumLevel") + .HasColumnType("smallint"); + + b.Property("MinimumLevel") + .HasColumnType("smallint"); + + b.Property("Rank") + .HasColumnType("smallint"); + + b.Property("ReplacedSkillId") + .HasColumnType("uuid"); + + b.Property("RootId") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.Property("ValueFormula") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ReplacedSkillId"); + + b.HasIndex("RootId"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("MasterSkillDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.Property("MasterSkillDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("MasterSkillDefinitionId", "SkillId"); + + b.HasIndex("SkillId"); + + b.ToTable("MasterSkillDefinitionSkill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("MasterSkillRoot", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("MinimumTargetLevel") + .HasColumnType("smallint"); + + b.Property("MultiplyKillsByPlayers") + .HasColumnType("boolean"); + + b.Property("NumberOfKills") + .HasColumnType("smallint"); + + b.Property("SpawnAreaId") + .HasColumnType("uuid"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("SpawnAreaId") + .IsUnique(); + + b.HasIndex("TargetDefinitionId"); + + b.ToTable("MiniGameChangeEvent", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AllowParty") + .HasColumnType("boolean"); + + b.Property("ArePlayerKillersAllowedToEnter") + .HasColumnType("boolean"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EnterDuration") + .HasColumnType("interval"); + + b.Property("EntranceFee") + .HasColumnType("integer"); + + b.Property("EntranceId") + .HasColumnType("uuid"); + + b.Property("ExitDuration") + .HasColumnType("interval"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GameDuration") + .HasColumnType("interval"); + + b.Property("GameLevel") + .HasColumnType("smallint"); + + b.Property("MapCreationPolicy") + .HasColumnType("integer"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MaximumPlayerCount") + .HasColumnType("integer"); + + b.Property("MaximumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumSpecialCharacterLevel") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("RequiresMasterClass") + .HasColumnType("boolean"); + + b.Property("SaveRankingStatistics") + .HasColumnType("boolean"); + + b.Property("TicketItemId") + .HasColumnType("uuid"); + + b.Property("TicketItemLevel") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EntranceId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("TicketItemId"); + + b.ToTable("MiniGameDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("GameInstanceId") + .HasColumnType("uuid"); + + b.Property("MiniGameId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("Score") + .HasColumnType("integer"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("MiniGameId"); + + b.ToTable("MiniGameRankingEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("Rank") + .HasColumnType("integer"); + + b.Property("RequiredKillId") + .HasColumnType("uuid"); + + b.Property("RequiredSuccess") + .HasColumnType("integer"); + + b.Property("RewardAmount") + .HasColumnType("integer"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ItemRewardId"); + + b.HasIndex("MiniGameDefinitionId"); + + b.HasIndex("RequiredKillId"); + + b.ToTable("MiniGameReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTime") + .HasColumnType("interval"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("MiniGameDefinitionId") + .HasColumnType("uuid"); + + b.Property("StartTime") + .HasColumnType("interval"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameDefinitionId"); + + b.ToTable("MiniGameSpawnWave", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("EndX") + .HasColumnType("smallint"); + + b.Property("EndY") + .HasColumnType("smallint"); + + b.Property("IsClientUpdateRequired") + .HasColumnType("boolean"); + + b.Property("MiniGameChangeEventId") + .HasColumnType("uuid"); + + b.Property("SetTerrainAttribute") + .HasColumnType("boolean"); + + b.Property("StartX") + .HasColumnType("smallint"); + + b.Property("StartY") + .HasColumnType("smallint"); + + b.Property("TerrainAttribute") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("MiniGameChangeEventId"); + + b.ToTable("MiniGameTerrainChange", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeDefinitionId") + .HasColumnType("uuid"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AttributeDefinitionId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterAttribute", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttackDelay") + .HasColumnType("interval"); + + b.Property("AttackRange") + .HasColumnType("smallint"); + + b.Property("AttackSkillId") + .HasColumnType("uuid"); + + b.Property("Attribute") + .HasColumnType("smallint"); + + b.Property("Designation") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IntelligenceTypeName") + .HasColumnType("text"); + + b.Property("MerchantStoreId") + .HasColumnType("uuid"); + + b.Property("MoveDelay") + .HasColumnType("interval"); + + b.Property("MoveRange") + .HasColumnType("smallint"); + + b.Property("NpcWindow") + .HasColumnType("integer"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfMaximumItemDrops") + .HasColumnType("integer"); + + b.Property("ObjectKind") + .HasColumnType("integer"); + + b.Property("RespawnDelay") + .HasColumnType("interval"); + + b.Property("ViewRange") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AttackSkillId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MerchantStoreId") + .IsUnique(); + + b.ToTable("MonsterDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.HasKey("MonsterDefinitionId", "DropItemGroupId"); + + b.HasIndex("DropItemGroupId"); + + b.ToTable("MonsterDefinitionDropItemGroup", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Direction") + .HasColumnType("integer"); + + b.Property("GameMapId") + .HasColumnType("uuid"); + + b.Property("MaximumHealthOverride") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Quantity") + .HasColumnType("smallint"); + + b.Property("SpawnTrigger") + .HasColumnType("integer"); + + b.Property("WaveNumber") + .HasColumnType("smallint"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameMapId"); + + b.HasIndex("MonsterDefinitionId"); + + b.ToTable("MonsterSpawnArea", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CustomConfiguration") + .HasColumnType("text"); + + b.Property("CustomPlugInSource") + .HasColumnType("text"); + + b.Property("ExternalAssemblyName") + .HasColumnType("text"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("TypeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.ToTable("PlugInConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BoostId") + .HasColumnType("uuid"); + + b.Property("GameMapDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId") + .HasColumnType("uuid"); + + b.Property("MagicEffectDefinitionId1") + .HasColumnType("uuid"); + + b.Property("TargetAttributeId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("BoostId") + .IsUnique(); + + b.HasIndex("GameMapDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId"); + + b.HasIndex("MagicEffectDefinitionId1"); + + b.HasIndex("TargetAttributeId"); + + b.ToTable("PowerUpDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AggregateType") + .HasColumnType("integer"); + + b.Property("MaximumValue") + .HasColumnType("real"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.ToTable("PowerUpDefinitionValue", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Group") + .HasColumnType("smallint"); + + b.Property("MaximumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MinimumCharacterLevel") + .HasColumnType("integer"); + + b.Property("MonsterDefinitionId") + .HasColumnType("uuid"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("QualifiedCharacterId") + .HasColumnType("uuid"); + + b.Property("QuestGiverId") + .HasColumnType("uuid"); + + b.Property("RefuseNumber") + .HasColumnType("smallint"); + + b.Property("Repeatable") + .HasColumnType("boolean"); + + b.Property("RequiredStartMoney") + .HasColumnType("integer"); + + b.Property("RequiresClientAction") + .HasColumnType("boolean"); + + b.Property("StartingNumber") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("MonsterDefinitionId"); + + b.HasIndex("QualifiedCharacterId"); + + b.HasIndex("QuestGiverId"); + + b.ToTable("QuestDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("DropItemGroupId") + .HasColumnType("uuid"); + + b.Property("ItemId") + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DropItemGroupId"); + + b.HasIndex("ItemId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestItemRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MinimumNumber") + .HasColumnType("integer"); + + b.Property("MonsterId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MonsterId"); + + b.HasIndex("QuestDefinitionId"); + + b.ToTable("QuestMonsterKillRequirement", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterQuestStateId") + .HasColumnType("uuid"); + + b.Property("KillCount") + .HasColumnType("integer"); + + b.Property("RequirementId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterQuestStateId"); + + b.HasIndex("RequirementId"); + + b.ToTable("QuestMonsterKillRequirementState", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeRewardId") + .HasColumnType("uuid"); + + b.Property("ItemRewardId") + .HasColumnType("uuid"); + + b.Property("QuestDefinitionId") + .HasColumnType("uuid"); + + b.Property("RewardType") + .HasColumnType("integer"); + + b.Property("SkillRewardId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AttributeRewardId"); + + b.HasIndex("ItemRewardId") + .IsUnique(); + + b.HasIndex("QuestDefinitionId"); + + b.HasIndex("SkillRewardId"); + + b.ToTable("QuestReward", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("X1") + .HasColumnType("smallint"); + + b.Property("X2") + .HasColumnType("smallint"); + + b.Property("Y1") + .HasColumnType("smallint"); + + b.Property("Y2") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.ToTable("Rectangle", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumSuccessPercent") + .HasColumnType("smallint"); + + b.Property("Money") + .HasColumnType("integer"); + + b.Property("MoneyPerFinalSuccessPercentage") + .HasColumnType("integer"); + + b.Property("MultipleAllowed") + .HasColumnType("boolean"); + + b.Property("NpcPriceDivisor") + .HasColumnType("integer"); + + b.Property("ResultItemExcellentOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemLuckOptionChance") + .HasColumnType("smallint"); + + b.Property("ResultItemMaxExcOptionCount") + .HasColumnType("smallint"); + + b.Property("ResultItemSelect") + .HasColumnType("integer"); + + b.Property("ResultItemSkillChance") + .HasColumnType("smallint"); + + b.Property("SuccessPercent") + .HasColumnType("smallint"); + + b.Property("SuccessPercentageAdditionForAncientItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForExcellentItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForGuardianItem") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForLuck") + .HasColumnType("integer"); + + b.Property("SuccessPercentageAdditionForSocketItem") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("SimpleCraftingSettings", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AreaSkillSettingsId") + .HasColumnType("uuid"); + + b.Property("AttackDamage") + .HasColumnType("integer"); + + b.Property("DamageType") + .HasColumnType("integer"); + + b.Property("ElementalModifierTargetId") + .HasColumnType("uuid"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("ImplicitTargetRange") + .HasColumnType("smallint"); + + b.Property("MagicEffectDefId") + .HasColumnType("uuid"); + + b.Property("MasterDefinitionId") + .HasColumnType("uuid"); + + b.Property("MovesTarget") + .HasColumnType("boolean"); + + b.Property("MovesToTarget") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("smallint"); + + b.Property("NumberOfHitsPerAttack") + .HasColumnType("smallint"); + + b.Property("Range") + .HasColumnType("smallint"); + + b.Property("SkillType") + .HasColumnType("integer"); + + b.Property("SkipElementalModifier") + .HasColumnType("boolean"); + + b.Property("Target") + .HasColumnType("integer"); + + b.Property("TargetRestriction") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("AreaSkillSettingsId") + .IsUnique(); + + b.HasIndex("ElementalModifierTargetId"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("MagicEffectDefId"); + + b.HasIndex("MasterDefinitionId") + .IsUnique(); + + b.ToTable("Skill", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.Property("SkillId") + .HasColumnType("uuid"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.HasKey("SkillId", "CharacterClassId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("SkillCharacterClass", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MaximumCompletionTime") + .HasColumnType("interval"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SkillComboDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsFinalStep") + .HasColumnType("boolean"); + + b.Property("Order") + .HasColumnType("integer"); + + b.Property("SkillComboDefinitionId") + .HasColumnType("uuid"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SkillComboDefinitionId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillComboStep", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("Level") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("SkillId"); + + b.ToTable("SkillEntry", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountId") + .HasColumnType("uuid"); + + b.Property("CharacterId") + .HasColumnType("uuid"); + + b.Property("DefinitionId") + .HasColumnType("uuid"); + + b.Property("Value") + .HasColumnType("real"); + + b.HasKey("Id"); + + b.HasIndex("AccountId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("DefinitionId"); + + b.ToTable("StatAttribute", "data"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AttributeId") + .HasColumnType("uuid"); + + b.Property("BaseValue") + .HasColumnType("real"); + + b.Property("CharacterClassId") + .HasColumnType("uuid"); + + b.Property("IncreasableByPlayer") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AttributeId"); + + b.HasIndex("CharacterClassId"); + + b.ToTable("StatAttributeDefinition", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SystemConfiguration", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AutoStart") + .HasColumnType("boolean"); + + b.Property("AutoUpdateSchema") + .HasColumnType("boolean"); + + b.Property("IpResolver") + .HasColumnType("integer"); + + b.Property("IpResolverParameter") + .HasColumnType("text"); + + b.Property("ReadConsoleInput") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.ToTable("SystemConfiguration", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Costs") + .HasColumnType("integer"); + + b.Property("GameConfigurationId") + .HasColumnType("uuid"); + + b.Property("GateId") + .HasColumnType("uuid"); + + b.Property("Index") + .HasColumnType("integer"); + + b.Property("LevelRequirement") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameConfigurationId"); + + b.HasIndex("GateId"); + + b.ToTable("WarpInfo", "config"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawVault") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "VaultId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawVault"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", "Account") + .WithMany("JoinedUnlockedCharacterClasses") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Account"); + + b.Navigation("CharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId"); + + b.Navigation("RawCharacterClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawAttributes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawAttributeCombinations") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawGlobalAttributeCombinations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawInputAttribute") + .WithMany() + .HasForeignKey("InputAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawOperandAttribute") + .WithMany() + .HasForeignKey("OperandAttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", null) + .WithMany("RawRelatedValues") + .HasForeignKey("PowerUpDefinitionValueId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawAttributeRelationships") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawInputAttribute"); + + b.Navigation("RawOperandAttribute"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawMapRequirements") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawConsumeRequirements") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", null) + .WithMany("RawRequirements") + .HasForeignKey("SkillId1") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawGround") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "GroundId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawLeftGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "LeftGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Rectangle", "RawRightGoal") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RightGoalId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawGround"); + + b.Navigation("RawLeftGoal"); + + b.Navigation("RawRightGoal"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawCharacters") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawCharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawCurrentMap") + .WithMany() + .HasForeignKey("CurrentMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawInventory") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "InventoryId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawCharacterClass"); + + b.Navigation("RawCurrentMap"); + + b.Navigation("RawInventory"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", "RawComboDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "ComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawCharacterClasses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawHomeMap") + .WithMany() + .HasForeignKey("HomeMapId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawNextGenerationClass") + .WithMany() + .HasForeignKey("NextGenerationClassId"); + + b.Navigation("RawComboDefinition"); + + b.Navigation("RawHomeMap"); + + b.Navigation("RawNextGenerationClass"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("DropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawActiveQuest") + .WithMany() + .HasForeignKey("ActiveQuestId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawQuestStates") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", "RawLastFinishedQuest") + .WithMany() + .HasForeignKey("LastFinishedQuestId"); + + b.Navigation("RawActiveQuest"); + + b.Navigation("RawLastFinishedQuest"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("ChatServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", null) + .WithMany("RawRequirements") + .HasForeignKey("ItemOptionCombinationBonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.Navigation("RawOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany("RawBaseAttributeValues") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "GameConfiguration") + .WithMany("RawGlobalBaseAttributeValues") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CharacterClass"); + + b.Navigation("GameConfiguration"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawDropItemGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", null) + .WithMany("RawDuelAreas") + .HasForeignKey("DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawFirstPlayerGate") + .WithMany() + .HasForeignKey("FirstPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSecondPlayerGate") + .WithMany() + .HasForeignKey("SecondPlayerGateId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawSpectatorsGate") + .WithMany() + .HasForeignKey("SpectatorsGateId"); + + b.Navigation("RawFirstPlayerGate"); + + b.Navigation("RawSecondPlayerGate"); + + b.Navigation("RawSpectatorsGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawExit") + .WithMany() + .HasForeignKey("ExitId"); + + b.Navigation("RawExit"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawEnterGates") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawTargetGate") + .WithMany() + .HasForeignKey("TargetGateId"); + + b.Navigation("RawTargetGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawMap") + .WithMany("RawExitGates") + .HasForeignKey("MapId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", "RawDuelConfiguration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "DuelConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDuelConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", "RawBattleZone") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "BattleZoneId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMaps") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawSafezoneMap") + .WithMany() + .HasForeignKey("SafezoneMapId"); + + b.Navigation("RawBattleZone"); + + b.Navigation("RawSafezoneMap"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("GameMapDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfigurationGameMapDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "GameMapDefinition") + .WithMany() + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "GameServerConfiguration") + .WithMany("JoinedMaps") + .HasForeignKey("GameServerConfigurationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GameMapDefinition"); + + b.Navigation("GameServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", "RawGameConfiguration") + .WithMany() + .HasForeignKey("GameConfigurationId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", "RawServerConfiguration") + .WithMany() + .HasForeignKey("ServerConfigurationId"); + + b.Navigation("RawGameConfiguration"); + + b.Navigation("RawServerConfiguration"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerEndpoint", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", "RawClient") + .WithMany() + .HasForeignKey("ClientId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", null) + .WithMany("RawEndpoints") + .HasForeignKey("GameServerDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawClient"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawAllianceGuild") + .WithMany() + .HasForeignKey("AllianceGuildId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", "RawHostility") + .WithMany() + .HasForeignKey("HostilityId"); + + b.Navigation("RawAllianceGuild"); + + b.Navigation("RawHostility"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GuildMember", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", null) + .WithMany("RawMembers") + .HasForeignKey("GuildId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Character") + .WithMany() + .HasForeignKey("Id") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", null) + .WithMany("RawPossibleOptions") + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawItemStorage") + .WithMany("RawItems") + .HasForeignKey("ItemStorageId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDefinition"); + + b.Navigation("RawItemStorage"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", null) + .WithMany("RawEquippedItems") + .HasForeignKey("AppearanceDataId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearanceItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", "ItemAppearance") + .WithMany("JoinedVisibleOptions") + .HasForeignKey("ItemAppearanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemAppearance"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemBasePowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", "RawBonusPerLevelTable") + .WithMany() + .HasForeignKey("BonusPerLevelTableId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawBasePowerUpAttributes") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBonusPerLevelTable"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawItemCraftings") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", "RawSimpleCraftingSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCrafting", "SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawSimpleCraftingSettings"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawRequiredItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItemItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", "ItemCraftingRequiredItem") + .WithMany("JoinedRequiredItemOptions") + .HasForeignKey("ItemCraftingRequiredItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "ItemOptionType") + .WithMany() + .HasForeignKey("ItemOptionTypeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemCraftingRequiredItem"); + + b.Navigation("ItemOptionType"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingResultItem", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", null) + .WithMany("RawResultItems") + .HasForeignKey("SimpleCraftingSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawConsumeEffect") + .WithMany() + .HasForeignKey("ConsumeEffectId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItems") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", "RawItemSlot") + .WithMany() + .HasForeignKey("ItemSlotId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawConsumeEffect"); + + b.Navigation("RawItemSlot"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("ItemDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemOptions") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "ItemOptionDefinition") + .WithMany() + .HasForeignKey("ItemOptionDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemOptionDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinitionItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany("JoinedPossibleItemSetGroups") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "ItemSetGroup") + .WithMany() + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", null) + .WithMany("RawDropItems") + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroupItemDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "ItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", "ItemDropItemGroup") + .WithMany("JoinedPossibleItems") + .HasForeignKey("ItemDropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ItemDefinition"); + + b.Navigation("ItemDropItemGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "Item") + .WithMany("JoinedItemSetGroups") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", "ItemOfItemSet") + .WithMany() + .HasForeignKey("ItemOfItemSetId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemOfItemSet"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemLevelBonusTables") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOfItemSet", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawBonusOption") + .WithMany() + .HasForeignKey("BonusOptionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItemDefinition") + .WithMany() + .HasForeignKey("ItemDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", "RawItemSetGroup") + .WithMany("RawItems") + .HasForeignKey("ItemSetGroupId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonusOption"); + + b.Navigation("RawItemDefinition"); + + b.Navigation("RawItemSetGroup"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", "RawOptionType") + .WithMany() + .HasForeignKey("OptionTypeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOption", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawOptionType"); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawBonus") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", "BonusId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionCombinationBonuses") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawBonus"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionLink", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", null) + .WithMany("RawItemOptions") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", "RawItemOption") + .WithMany() + .HasForeignKey("ItemOptionId"); + + b.Navigation("RawItemOption"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", null) + .WithMany("RawLevelDependentOptions") + .HasForeignKey("IncreasableItemOptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "RawPowerUpDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionOfLevel", "PowerUpDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawPowerUpDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemOptionTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSetGroups") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", "RawOptions") + .WithMany() + .HasForeignKey("OptionsId"); + + b.Navigation("RawOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSlotType", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawItemSlotTypes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.JewelMix", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawJewelMixes") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawMixedJewel") + .WithMany() + .HasForeignKey("MixedJewelId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawSingleJewel") + .WithMany() + .HasForeignKey("SingleJewelId"); + + b.Navigation("RawMixedJewel"); + + b.Navigation("RawSingleJewel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", "RawHeader") + .WithMany() + .HasForeignKey("HeaderId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", "RawSenderAppearance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterBody", "SenderAppearanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawHeader"); + + b.Navigation("RawSenderAppearance"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LetterHeader", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "Receiver") + .WithMany("RawLetters") + .HasForeignKey("ReceiverId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Receiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.LevelBonus", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", null) + .WithMany("RawBonusPerLevel") + .HasForeignKey("ItemLevelBonusTableId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChance") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChanceId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawChancePvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "ChancePvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDuration") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawDurationPvp") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "DurationPvpId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMagicEffects") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawChance"); + + b.Navigation("RawChancePvp"); + + b.Navigation("RawDuration"); + + b.Navigation("RawDurationPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawReplacedSkill") + .WithMany() + .HasForeignKey("ReplacedSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", "RawRoot") + .WithMany() + .HasForeignKey("RootId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawReplacedSkill"); + + b.Navigation("RawRoot"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinitionSkill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "MasterSkillDefinition") + .WithMany("JoinedRequiredMasterSkills") + .HasForeignKey("MasterSkillDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany() + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MasterSkillDefinition"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillRoot", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMasterSkillRoots") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawChangeEvents") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", "RawSpawnArea") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", "SpawnAreaId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawTargetDefinition") + .WithMany() + .HasForeignKey("TargetDefinitionId"); + + b.Navigation("RawSpawnArea"); + + b.Navigation("RawTargetDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawEntrance") + .WithMany() + .HasForeignKey("EntranceId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMiniGameDefinitions") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawTicketItem") + .WithMany() + .HasForeignKey("TicketItemId"); + + b.Navigation("RawEntrance"); + + b.Navigation("RawTicketItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameRankingEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", "RawCharacter") + .WithMany() + .HasForeignKey("CharacterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", "RawMiniGame") + .WithMany() + .HasForeignKey("MiniGameId"); + + b.Navigation("RawCharacter"); + + b.Navigation("RawMiniGame"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawItemReward") + .WithMany() + .HasForeignKey("ItemRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawRequiredKill") + .WithMany() + .HasForeignKey("RequiredKillId"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawRequiredKill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameSpawnWave", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", null) + .WithMany("RawSpawnWaves") + .HasForeignKey("MiniGameDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameTerrainChange", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", null) + .WithMany("RawTerrainChanges") + .HasForeignKey("MiniGameChangeEventId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeDefinition") + .WithMany() + .HasForeignKey("AttributeDefinitionId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawAttributes") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttributeDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawAttackSkill") + .WithMany() + .HasForeignKey("AttackSkillId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawMonsters") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", "RawMerchantStore") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MerchantStoreId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttackSkill"); + + b.Navigation("RawMerchantStore"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinitionDropItemGroup", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "DropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "MonsterDefinition") + .WithMany("JoinedDropItemGroups") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("DropItemGroup"); + + b.Navigation("MonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterSpawnArea", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", "RawGameMap") + .WithMany("RawMonsterSpawns") + .HasForeignKey("GameMapId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonsterDefinition") + .WithMany() + .HasForeignKey("MonsterDefinitionId"); + + b.Navigation("RawGameMap"); + + b.Navigation("RawMonsterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PlugInConfiguration", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawPlugInConfigurations") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", "RawBoost") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinition", "BoostId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", null) + .WithMany("RawCharacterPowerUpDefinitions") + .HasForeignKey("GameMapDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitions") + .HasForeignKey("MagicEffectDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", null) + .WithMany("RawPowerUpDefinitionsPvp") + .HasForeignKey("MagicEffectDefinitionId1") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("FK_PowerUpDefinition_MagicEffectDefinition_MagicEffectDefinit~1"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawTargetAttribute") + .WithMany() + .HasForeignKey("TargetAttributeId"); + + b.Navigation("RawBoost"); + + b.Navigation("RawTargetAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", null) + .WithMany("RawQuests") + .HasForeignKey("MonsterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "RawQualifiedCharacter") + .WithMany() + .HasForeignKey("QualifiedCharacterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawQuestGiver") + .WithMany() + .HasForeignKey("QuestGiverId"); + + b.Navigation("RawQualifiedCharacter"); + + b.Navigation("RawQuestGiver"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestItemRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", "RawDropItemGroup") + .WithMany() + .HasForeignKey("DropItemGroupId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", "RawItem") + .WithMany() + .HasForeignKey("ItemId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredItems") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawDropItemGroup"); + + b.Navigation("RawItem"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", "RawMonster") + .WithMany() + .HasForeignKey("MonsterId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRequiredMonsterKills") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawMonster"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirementState", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", null) + .WithMany("RawRequirementStates") + .HasForeignKey("CharacterQuestStateId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestMonsterKillRequirement", "RawRequirement") + .WithMany() + .HasForeignKey("RequirementId"); + + b.Navigation("RawRequirement"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttributeReward") + .WithMany() + .HasForeignKey("AttributeRewardId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", "RawItemReward") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestReward", "ItemRewardId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", null) + .WithMany("RawRewards") + .HasForeignKey("QuestDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkillReward") + .WithMany() + .HasForeignKey("SkillRewardId"); + + b.Navigation("RawAttributeReward"); + + b.Navigation("RawItemReward"); + + b.Navigation("RawSkillReward"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", "RawAreaSkillSettings") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "AreaSkillSettingsId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawElementalModifierTarget") + .WithMany() + .HasForeignKey("ElementalModifierTargetId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawSkills") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", "RawMagicEffectDef") + .WithMany() + .HasForeignKey("MagicEffectDefId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", "RawMasterDefinition") + .WithOne() + .HasForeignKey("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "MasterDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAreaSkillSettings"); + + b.Navigation("RawElementalModifierTarget"); + + b.Navigation("RawMagicEffectDef"); + + b.Navigation("RawMasterDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillCharacterClass", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", "CharacterClass") + .WithMany() + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "Skill") + .WithMany("JoinedQualifiedCharacters") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CharacterClass"); + + b.Navigation("Skill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboStep", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", null) + .WithMany("RawSteps") + .HasForeignKey("SkillComboDefinitionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillEntry", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawLearnedSkills") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", "RawSkill") + .WithMany() + .HasForeignKey("SkillId"); + + b.Navigation("RawSkill"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttribute", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", null) + .WithMany("RawAttributes") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", null) + .WithMany("RawAttributes") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawDefinition") + .WithMany() + .HasForeignKey("DefinitionId"); + + b.Navigation("RawDefinition"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.StatAttributeDefinition", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", "RawAttribute") + .WithMany() + .HasForeignKey("AttributeId"); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", null) + .WithMany("RawStatAttributes") + .HasForeignKey("CharacterClassId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("RawAttribute"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.WarpInfo", b => + { + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", null) + .WithMany("RawWarpList") + .HasForeignKey("GameConfigurationId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", "RawGate") + .WithMany() + .HasForeignKey("GateId"); + + b.Navigation("RawGate"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b => + { + b.Navigation("JoinedUnlockedCharacterClasses"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacters"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b => + { + b.Navigation("RawEquippedItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawLearnedSkills"); + + b.Navigation("RawLetters"); + + b.Navigation("RawQuestStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b => + { + b.Navigation("RawAttributeCombinations"); + + b.Navigation("RawBaseAttributeValues"); + + b.Navigation("RawStatAttributes"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b => + { + b.Navigation("RawRequirementStates"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b => + { + b.Navigation("RawDuelAreas"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b => + { + b.Navigation("RawAttributes"); + + b.Navigation("RawCharacterClasses"); + + b.Navigation("RawDropItemGroups"); + + b.Navigation("RawGlobalAttributeCombinations"); + + b.Navigation("RawGlobalBaseAttributeValues"); + + b.Navigation("RawItemLevelBonusTables"); + + b.Navigation("RawItemOptionCombinationBonuses"); + + b.Navigation("RawItemOptionTypes"); + + b.Navigation("RawItemOptions"); + + b.Navigation("RawItemSetGroups"); + + b.Navigation("RawItemSlotTypes"); + + b.Navigation("RawItems"); + + b.Navigation("RawJewelMixes"); + + b.Navigation("RawMagicEffects"); + + b.Navigation("RawMaps"); + + b.Navigation("RawMasterSkillRoots"); + + b.Navigation("RawMiniGameDefinitions"); + + b.Navigation("RawMonsters"); + + b.Navigation("RawPlugInConfigurations"); + + b.Navigation("RawSkills"); + + b.Navigation("RawWarpList"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameMapDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawCharacterPowerUpDefinitions"); + + b.Navigation("RawEnterGates"); + + b.Navigation("RawExitGates"); + + b.Navigation("RawMapRequirements"); + + b.Navigation("RawMonsterSpawns"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerConfiguration", b => + { + b.Navigation("JoinedMaps"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameServerDefinition", b => + { + b.Navigation("RawEndpoints"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Guild", b => + { + b.Navigation("RawMembers"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.IncreasableItemOption", b => + { + b.Navigation("RawLevelDependentOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Item", b => + { + b.Navigation("JoinedItemSetGroups"); + + b.Navigation("RawItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemAppearance", b => + { + b.Navigation("JoinedVisibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemCraftingRequiredItem", b => + { + b.Navigation("JoinedPossibleItems"); + + b.Navigation("JoinedRequiredItemOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDefinition", b => + { + b.Navigation("JoinedPossibleItemOptions"); + + b.Navigation("JoinedPossibleItemSetGroups"); + + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawBasePowerUpAttributes"); + + b.Navigation("RawDropItems"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemDropItemGroup", b => + { + b.Navigation("JoinedPossibleItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemLevelBonusTable", b => + { + b.Navigation("RawBonusPerLevel"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionCombinationBonus", b => + { + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemOptionDefinition", b => + { + b.Navigation("RawPossibleOptions"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemSetGroup", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ItemStorage", b => + { + b.Navigation("RawItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MagicEffectDefinition", b => + { + b.Navigation("RawPowerUpDefinitions"); + + b.Navigation("RawPowerUpDefinitionsPvp"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MasterSkillDefinition", b => + { + b.Navigation("JoinedRequiredMasterSkills"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameChangeEvent", b => + { + b.Navigation("RawTerrainChanges"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MiniGameDefinition", b => + { + b.Navigation("RawChangeEvents"); + + b.Navigation("RawRewards"); + + b.Navigation("RawSpawnWaves"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.MonsterDefinition", b => + { + b.Navigation("JoinedDropItemGroups"); + + b.Navigation("RawAttributes"); + + b.Navigation("RawItemCraftings"); + + b.Navigation("RawQuests"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.PowerUpDefinitionValue", b => + { + b.Navigation("RawRelatedValues"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.QuestDefinition", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawRequiredMonsterKills"); + + b.Navigation("RawRewards"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SimpleCraftingSettings", b => + { + b.Navigation("RawRequiredItems"); + + b.Navigation("RawResultItems"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Skill", b => + { + b.Navigation("JoinedQualifiedCharacters"); + + b.Navigation("RawAttributeRelationships"); + + b.Navigation("RawConsumeRequirements"); + + b.Navigation("RawRequirements"); + }); + + modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.SkillComboDefinition", b => + { + b.Navigation("RawSteps"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs index f6a71e046..3f738e560 100644 --- a/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs +++ b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs @@ -6,13 +6,9 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations { - using Microsoft.EntityFrameworkCore; - using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; /// - [DbContext(typeof(EntityDataContext))] - [Migration("20260627120000_AddAccountIsBot")] public partial class AddAccountIsBot : Migration { /// From db0e796299d705c64f6cfc34b97f1901956f104e Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 13 Jul 2026 08:25:09 +0200 Subject: [PATCH 37/60] Grant bots their wings at the classic level milestones Wings don't drop from monsters, so the loot-driven equipment progression never provides them; a bot now earns them like a player who saves up: the first pair at level 180 (+0, luck, +12 option), the second at 280 (+9, luck, +16) and the third at 400 (+15, luck, +16). The pair is created directly into the wing slot - the planner already guarantees the class qualification and level requirement a regular equip would check, the backpack is usually too crammed with loot for a wing's footprint anyway, and the slot placement mounts the power-ups and broadcasts the appearance like a regular equip. It is queued into the MuHelper tick like every other bot-initiated mutation. The outgrown pair is destroyed - never dropped, so no player can grab a conjured wing from the ground. Which classes may wear which pair comes from the item data itself: the third tier (master classes only) is simply not offered to a bot which did not evolve yet, the Dark Lord/Rage Fighter capes - their only pre-master wing - are re-granted as a fresh +9 cape at the second milestone (the Cape of Lord lives in group 13, unlike all other wings), and a Magic Gladiator picks the pair matching its fighting style. A bot re-levelling through the lower milestones after a reset keeps its better wings. --- src/GameLogic/Bots/BotNavigator.cs | 5 + src/GameLogic/Bots/BotWingHandler.cs | 263 ++++++++++++++++++ .../BotWingHandlerTest.cs | 225 +++++++++++++++ 3 files changed, 493 insertions(+) create mode 100644 src/GameLogic/Bots/BotWingHandler.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 613a198b0..f318aced5 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -325,6 +325,11 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) { this._nextEquipCheckUtc = DateTime.UtcNow + EquipCheckInterval; this._player.PendingBotActions.Enqueue(() => BotEquipmentHandler.TryEquipUpgradesAsync(this._player)); + + // Wings don't drop, so the loot-driven equipment progression above never provides them; + // they are earned at the classic level milestones instead (see BotWingHandler). Queued + // for the same reason: equipping mounts item power-ups. + this._player.PendingBotActions.Enqueue(() => BotWingHandler.TryAdvanceWingsAsync(this._player)); } // Keep the combat centre on the bot's current position, so the combat handler always engages diff --git a/src/GameLogic/Bots/BotWingHandler.cs b/src/GameLogic/Bots/BotWingHandler.cs new file mode 100644 index 000000000..094f731c8 --- /dev/null +++ b/src/GameLogic/Bots/BotWingHandler.cs @@ -0,0 +1,263 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// Grants a bot its wings at the classic level milestones, like a player who saves up for them: +/// at level the first pair (+0, luck, +12 option), at +/// the second pair (+9, luck, +16 option) and at +/// the third pair (+15, luck, +16 option). Wings don't drop from +/// monsters, so they are created directly and put straight into the wing slot - the planner +/// guarantees the class qualification and level requirement a regular equip would check, and the +/// slot placement mounts the power-ups like one. The outgrown pair is destroyed - never dropped, +/// so no player can grab a made-up wing from the ground. +/// The wing model of the item data does the rest: which classes may wear which pair is defined by +/// , so e.g. the third-tier wings (master classes +/// only) are simply not offered to a bot which did not evolve yet, and the Dark Lord/Rage Fighter +/// capes - their only pre-master wing - are re-granted as a fresh +9 cape at the second milestone. +/// +internal static class BotWingHandler +{ + /// The level at which a bot gets its first tier wings (+0, luck, +12 option). + private const int FirstTierLevel = 180; + + /// The level at which a bot gets its second tier wings (+9, luck, +16 option). + private const int SecondTierLevel = 280; + + /// The level at which a bot gets its third tier wings (+15, luck, +16 option). + private const int ThirdTierLevel = 400; + + /// The item level of the second tier grant; also disambiguates the tier of a cape (see ). + private const byte SecondTierItemLevel = 9; + + /// First tier wing numbers (group 12): Wings of Elf, Heaven, Satan and Curse. + private static readonly (byte Group, short Number)[] FirstTierIds = { (12, 0), (12, 1), (12, 2), (12, 41) }; + + /// Second tier wing numbers (group 12): Wings of Spirits, Soul, Dragon, Darkness and Despair. + private static readonly (byte Group, short Number)[] SecondTierIds = { (12, 3), (12, 4), (12, 5), (12, 6), (12, 42) }; + + /// Third tier wing numbers (group 12): Wing of Storm, Eternal, Illusion, Ruin, Dimension and the Capes of Emperor/Overrule. + private static readonly (byte Group, short Number)[] ThirdTierIds = { (12, 36), (12, 37), (12, 38), (12, 39), (12, 40), (12, 43), (12, 50) }; + + /// + /// The Cape of Lord (13, 30, Dark Lord) and Cape of Fighter (12, 49, Rage Fighter): the only + /// pre-master wing of their classes, granted at the first milestone at +0 and again at the + /// second as a fresh +9 cape. Unlike all other wings, the Cape of Lord lives in group 13 - + /// group 12 number 30 is the Packed Jewel of Bless (which the wing-slot filter of + /// would keep out of the candidates anyway). + /// + private static readonly (byte Group, short Number)[] CapeIds = { (13, 30), (12, 49) }; + + /// + /// Checks the bot's level milestones and puts on the earned wings; called from the bot's regular + /// evaluation cadence, queued into the MuHelper tick because equipping mounts item power-ups. + /// + /// The bot player. + public static async ValueTask TryAdvanceWingsAsync(OfflinePlayer player) + { + if (PlanNextGrant(player) is not { } plan || player.Inventory is not { } inventory) + { + return; + } + + // The outgrown pair is destroyed, not dropped - a conjured wing must never lie on the + // ground for a player to pick up. Clearing the slot first also keeps the grant independent + // of the backpack, which is usually too crammed with loot for a wing's 5x3 footprint. + if (inventory.GetItem(InventoryConstants.WingsSlot) is { } outgrown) + { + await player.DestroyInventoryItemAsync(outgrown).ConfigureAwait(false); + player.Logger.LogInformation("Bot '{Name}' discarded its outgrown wings '{Wings}'.", player.Name, outgrown); + } + + // Directly into the wing slot: the planner already guarantees the class qualification and + // the level requirement, and the slot placement mounts the power-ups and broadcasts the + // changed appearance like a regular equip. + var wings = CreateWings(player, plan); + if (!await inventory.AddItemAsync(InventoryConstants.WingsSlot, wings).ConfigureAwait(false)) + { + // Shouldn't happen - the slot was just cleared; don't leak the created item. + await player.PersistenceContext.DeleteAsync(wings).ConfigureAwait(false); + player.Logger.LogWarning("Bot '{Name}' could not equip its new wings '{Wings}'.", player.Name, wings); + return; + } + + player.Logger.LogInformation("Bot '{Name}' earned its tier {Tier} wings: '{Wings}'.", player.Name, plan.Tier, wings); + + try + { + // Persist right away like after using jewels - a milestone shouldn't be lost (and the + // outgrown pair resurrected) by a crash before the next periodic save. + await player.SaveProgressAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + player.Logger.LogWarning(ex, "Couldn't save bot '{Name}' right after granting wings; the periodic save will retry.", player.Name); + } + } + + /// + /// Determines the wings the bot has earned but does not wear yet, or null when it already + /// wears its best earned pair (or none is due). Pure decision logic - exposed for unit tests. + /// + /// The bot player. + internal static (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier)? PlanNextGrant(Player player) + { + if (player.Inventory is not { } inventory + || player.SelectedCharacter?.CharacterClass is not { } characterClass + || player.Attributes is not { } attributes) + { + return null; + } + + var level = (int)attributes[Stats.Level]; + var earnedTier = level switch + { + >= ThirdTierLevel => 3, + >= SecondTierLevel => 2, + >= FirstTierLevel => 1, + _ => 0, + }; + + // Walk down from the earned tier to the best one the class currently qualifies for: a bot + // which did not evolve into its master class yet simply isn't qualified for the third tier + // wings and keeps its second pair until the evolution. + for (var tier = earnedTier; tier >= 1; tier--) + { + var candidates = player.GameContext.Configuration.Items + .Where(d => TierIds(tier).Contains((d.Group, d.Number)) + && d.ItemSlot?.ItemSlots.Contains(InventoryConstants.WingsSlot) == true + && d.QualifiedCharacters.Contains(characterClass)) + .ToList(); + if (candidates.Count == 0) + { + continue; + } + + if (inventory.GetItem(InventoryConstants.WingsSlot) is { } equipped && TierOf(equipped) >= tier) + { + // Already wearing this tier (or a better one, e.g. right after a reset while + // re-levelling through the lower milestones) - never downgrade. + return null; + } + + var isCaster = attributes[Stats.TotalEnergy] > attributes[Stats.TotalStrength]; + var definition = candidates.MaxBy(d => FindWingOption(d, isCaster).Score)!; + var (itemLevel, optionLevel) = tier switch + { + 3 => ((byte)15, 4), + 2 => (SecondTierItemLevel, 4), + _ => ((byte)0, 3), + }; + + return (definition, itemLevel, optionLevel, tier); + } + + return null; + } + + private static IReadOnlyCollection<(byte Group, short Number)> TierIds(int tier) + { + return tier switch + { + 3 => ThirdTierIds, + 2 => SecondTierIds.Concat(CapeIds).ToList(), + _ => FirstTierIds.Concat(CapeIds).ToList(), + }; + } + + /// + /// The tier a worn wing counts as; the capes are the first-tier grant of their classes but count + /// as the second one once re-granted at +9. + /// + private static int TierOf(Item wings) + { + if (wings.Definition is not { } definition) + { + return 0; + } + + var id = (definition.Group, definition.Number); + if (ThirdTierIds.Contains(id)) + { + return 3; + } + + if (CapeIds.Contains(id)) + { + return wings.Level >= SecondTierItemLevel ? 2 : 1; + } + + if (SecondTierIds.Contains(id)) + { + return 2; + } + + return FirstTierIds.Contains(id) ? 1 : 0; + } + + /// + /// Picks the wing's "additional" option (the +4-per-level one, ) + /// which fits the bot's fighting style best - wizardry damage for casters, physical damage + /// otherwise - and scores it, so wings offering the matching damage option (the Magic Gladiator + /// may wear both Wings of Heaven and Satan) win the candidate selection. + /// + private static (IncreasableItemOption? Option, int Score) FindWingOption(ItemDefinition definition, bool isCaster) + { + static int ScoreOf(IncreasableItemOption option, bool isCaster) + { + var target = option.PowerUpDefinition?.TargetAttribute; + if (target == Stats.WizardryBaseDmg || target == Stats.CurseBaseDmg) + { + return isCaster ? 3 : 1; + } + + if (target == Stats.PhysicalBaseDmg) + { + return isCaster ? 1 : 3; + } + + return 0; + } + + return definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions) + .Where(o => o.OptionType == ItemOptionTypes.Option) + .Select(o => ((IncreasableItemOption?)o, ScoreOf(o, isCaster))) + .OrderByDescending(pair => pair.Item2) + .FirstOrDefault(); + } + + private static Item CreateWings(OfflinePlayer player, (ItemDefinition Definition, byte ItemLevel, int OptionLevel, int Tier) plan) + { + var item = player.PersistenceContext.CreateNew(); + item.Definition = plan.Definition; + item.Level = plan.ItemLevel; + item.Durability = plan.Definition.Durability; + + if (plan.Definition.PossibleItemOptions + .SelectMany(o => o.PossibleOptions) + .FirstOrDefault(o => o.OptionType == ItemOptionTypes.Luck) is { } luck) + { + var luckLink = player.PersistenceContext.CreateNew(); + luckLink.ItemOption = luck; + item.ItemOptions.Add(luckLink); + } + + var isCaster = player.Attributes![Stats.TotalEnergy] > player.Attributes[Stats.TotalStrength]; + if (FindWingOption(plan.Definition, isCaster).Option is { } option) + { + var optionLink = player.PersistenceContext.CreateNew(); + optionLink.ItemOption = option; + optionLink.Level = plan.OptionLevel; + item.ItemOptions.Add(optionLink); + } + + return item; + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs new file mode 100644 index 000000000..be6624d6f --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotWingHandlerTest.cs @@ -0,0 +1,225 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Attributes; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests the wing milestone policy of - which wings a bot has earned +/// at which level; the creation and equipping go through the regular persistence context and +/// . +/// +[TestFixture] +public class BotWingHandlerTest +{ + private const short FirstTierWingNumber = 2; + private const short SecondTierWingNumber = 5; + private const short ThirdTierWingNumber = 36; + + /// The Cape of Lord lives in group 13, unlike all other wings (group 12). + private const short CapeNumber = 30; + private const byte CapeGroup = 13; + + /// + /// Below the first milestone no wings are due. + /// + [Test] + public async ValueTask PlansNothingBelowFirstMilestoneAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg); + player.Attributes![Stats.Level] = 179; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Null); + } + + /// + /// At the milestones the earned tier is granted with the agreed item level and option level. + /// + /// The character level. + /// The expected wing number. + /// The expected item level of the grant. + /// The expected level of the wing option. + [TestCase(180, FirstTierWingNumber, 0, 3)] + [TestCase(280, SecondTierWingNumber, 9, 4)] + [TestCase(400, ThirdTierWingNumber, 15, 4)] + public async ValueTask GrantsEarnedTierAtMilestoneAsync(int level, short expectedNumber, byte expectedItemLevel, int expectedOptionLevel) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg); + AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg); + AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg); + player.Attributes![Stats.Level] = level; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Not.Null); + Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber)); + Assert.That(plan.Value.ItemLevel, Is.EqualTo(expectedItemLevel)); + Assert.That(plan.Value.OptionLevel, Is.EqualTo(expectedOptionLevel)); + } + + /// + /// A bot re-levelling through the lower milestones after a reset keeps its better wings. + /// + [Test] + public async ValueTask NeverDowngradesWornWingsAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + AddWingDefinition(player, FirstTierWingNumber, Stats.PhysicalBaseDmg); + var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg); + await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false); + player.Attributes![Stats.Level] = 200; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Null); + } + + /// + /// Wearing the earned tier already: nothing to do. + /// + [Test] + public async ValueTask PlansNothingWhenEarnedTierIsWornAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var secondTier = AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg); + await WearWingsAsync(player, secondTier, 9).ConfigureAwait(false); + player.Attributes![Stats.Level] = 300; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Null); + } + + /// + /// A bot which did not evolve into its master class yet is not qualified for the third tier + /// wings and falls back to the best qualified tier. + /// + [Test] + public async ValueTask FallsBackWhenThirdTierIsNotQualifiedAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + AddWingDefinition(player, SecondTierWingNumber, Stats.PhysicalBaseDmg); + var thirdTier = AddWingDefinition(player, ThirdTierWingNumber, Stats.PhysicalBaseDmg); + thirdTier.QualifiedCharacters.Clear(); + player.Attributes![Stats.Level] = 400; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Not.Null); + Assert.That(plan!.Value.Definition.Number, Is.EqualTo(SecondTierWingNumber)); + Assert.That(plan.Value.Tier, Is.EqualTo(2)); + } + + /// + /// The capes are the only pre-master wing of their classes: granted at the first milestone at +0 + /// and re-granted as a fresh +9 cape at the second one. + /// + /// The item level of the worn cape. + /// Whether a new cape is expected. + [TestCase(0, true)] + [TestCase(9, false)] + public async ValueTask RegrantsCapeAtSecondMilestoneAsync(byte wornCapeLevel, bool expectsGrant) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var cape = AddWingDefinition(player, CapeNumber, Stats.PhysicalBaseDmg, CapeGroup); + await WearWingsAsync(player, cape, wornCapeLevel).ConfigureAwait(false); + player.Attributes![Stats.Level] = 280; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan.HasValue, Is.EqualTo(expectsGrant)); + if (expectsGrant) + { + Assert.That(plan!.Value.Definition, Is.SameAs(cape)); + Assert.That(plan.Value.ItemLevel, Is.EqualTo(9)); + } + } + + /// + /// When a class qualifies for more than one pair (the Magic Gladiator may wear both Wings of + /// Heaven and Satan), the pair whose option matches the fighting style wins. + /// + /// The bot's base energy (base strength is 28). + /// The expected wing number. + [TestCase(200, 1)] + [TestCase(0, 2)] + public async ValueTask PrefersWingsMatchingFightingStyleAsync(int baseEnergy, short expectedNumber) + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + AddWingDefinition(player, 1, Stats.WizardryBaseDmg); + AddWingDefinition(player, 2, Stats.PhysicalBaseDmg); + player.Attributes![Stats.BaseEnergy] = baseEnergy; + player.Attributes[Stats.Level] = 180; + + var plan = BotWingHandler.PlanNextGrant(player); + + Assert.That(plan, Is.Not.Null); + Assert.That(plan!.Value.Definition.Number, Is.EqualTo(expectedNumber)); + } + + private static ItemDefinition AddWingDefinition(Player player, short number, AttributeDefinition optionTarget, byte group = 12) + { + var definitionMock = new Mock(); + definitionMock.SetupAllProperties(); + definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List()); + definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List()); + var slotType = new Mock(); + slotType.Setup(s => s.ItemSlots).Returns(new List { InventoryConstants.WingsSlot }); + definitionMock.Setup(d => d.ItemSlot).Returns(slotType.Object); + + var definition = definitionMock.Object; + definition.Group = group; + definition.Number = number; + definition.Width = 5; + definition.Height = 3; + definition.Durability = 200; + definition.MaximumItemLevel = 15; + definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!); + + var optionDefinitionMock = new Mock(); + optionDefinitionMock.SetupAllProperties(); + optionDefinitionMock.Setup(o => o.PossibleOptions).Returns(new List()); + var optionMock = new Mock(); + optionMock.SetupAllProperties(); + var powerUpMock = new Mock(); + powerUpMock.SetupAllProperties(); + powerUpMock.Object.TargetAttribute = optionTarget; + optionMock.Object.OptionType = ItemOptionTypes.Option; + optionMock.Object.PowerUpDefinition = powerUpMock.Object; + optionDefinitionMock.Object.PossibleOptions.Add(optionMock.Object); + definition.PossibleItemOptions.Add(optionDefinitionMock.Object); + + player.GameContext.Configuration.Items.Add(definition); + return definition; + } + + private static async ValueTask WearWingsAsync(Player player, ItemDefinition definition, byte level) + { + var itemMock = new Mock(); + itemMock.SetupAllProperties(); + itemMock.Setup(i => i.ItemOptions).Returns(new List()); + itemMock.Setup(i => i.ItemSetGroups).Returns(new List()); + var item = itemMock.Object; + item.Definition = definition; + item.Level = level; + item.Durability = definition.Durability; + + await player.Inventory!.AddItemAsync(InventoryConstants.WingsSlot, item).ConfigureAwait(false); + return item; + } +} From dc051cc5c10b316cb7defe3f6d327e4180d38749 Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 13 Jul 2026 11:03:57 +0200 Subject: [PATCH 38/60] Let party bots follow their leader into the mini game events A server-side bot may take part in Blood Castle, Devil Square and Chaos Castle - but only ever in the wake of a human: when a real player who leads a party with bots enters an event with their own ticket, the party's bots follow them in, without tickets of their own. Each bot is checked against the same entry restrictions a player faces (the event's level bracket, the master class requirement, the player killer rule); a bot which does not qualify says goodbye and leaves the party to go back to its own hunting life, like a player who cannot join the run. For an event which disallows parties (Chaos Castle), the followers are captured before the entry dissolves the leader's party. Inside, the bot's whole open-world routine is suspended - no shopping, no map changes, no party boredom, no invitations - and replaced by the event pace: fight what the event throws at it (without the open-world safe-level filtering), keep up with the leader on the way through the course, close in on the action when it moved away. In Chaos Castle, where the event allows player killing, the other participants join the bot's target pool and its area skills splash them like anyone else's - while such fights leave no grudge outside: the aggressor memory and the revenge march ignore anything that happens inside an event. Death needs no special handling: the engine respawns a dead bot at the map's safezone like a real player, which removes it from the event; the event itself warps the remaining participants out when it ends. --- src/GameLogic/Bots/BotMiniGameHandler.cs | 168 ++++++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 105 ++++++++++- src/GameLogic/Bots/BotPartyHandler.cs | 4 +- src/GameLogic/Bots/BotPvpRules.cs | 11 ++ src/GameLogic/Bots/BotRevengePlugIn.cs | 1 + src/GameLogic/Bots/BotSelfDefensePlugIn.cs | 1 + src/GameLogic/Offline/CombatHandler.cs | 82 +++++++-- .../MiniGames/EnterMiniGameAction.cs | 8 + .../BotMiniGameHandlerTest.cs | 143 +++++++++++++++ 9 files changed, 507 insertions(+), 16 deletions(-) create mode 100644 src/GameLogic/Bots/BotMiniGameHandler.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs diff --git a/src/GameLogic/Bots/BotMiniGameHandler.cs b/src/GameLogic/Bots/BotMiniGameHandler.cs new file mode 100644 index 000000000..829ed8ae3 --- /dev/null +++ b/src/GameLogic/Bots/BotMiniGameHandler.cs @@ -0,0 +1,168 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.MiniGames; +using MUnique.OpenMU.GameLogic.Offline; +using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames; + +/// +/// Lets server-side bots take part in the mini game events (Blood Castle, Devil Square, Chaos +/// Castle) - but only ever in the wake of a human: when a real player who leads a party with bots +/// enters an event with their own ticket, the party's bots follow them in. Bots never enter on +/// their own, and they don't need tickets - the leader's entry is what legitimizes the visit. +/// Each bot is checked against the same entry rules a player faces (the event level bracket, +/// the master class requirement, the player killer restriction); a bot which does not qualify +/// says goodbye and leaves the party to go back to its own hunting life, like a player who cannot +/// join the run. Inside, the bot's routine switches to the event mode of the +/// ; death and the event's end need no special handling, because the +/// engine respawns a dead bot at the map's safezone (exactly like a player, which removes it from +/// the event) and the event itself warps the remaining participants out when it ends. +/// +internal static class BotMiniGameHandler +{ + /// + /// Takes the snapshot of the bots which would follow the given player into a mini game. + /// Must be called BEFORE the player actually enters: entering an event which disallows + /// parties (Chaos Castle) kicks the entering player out of its party, and with it the + /// knowledge of who was going to follow. + /// + /// The player about to enter a mini game. + /// The party bots to bring along; empty when the player is a bot itself, has no party or is not its master. + internal static IReadOnlyList SnapshotPartyBots(Player player) + { + if (player is OfflinePlayer + || player.Party is not { } party + || !ReferenceEquals(party.PartyMaster, player)) + { + return []; + } + + return party.PartyList + .OfType() + .Where(bot => bot.Account?.IsBot == true) + .ToList(); + } + + /// + /// Brings the party bots of a player who just successfully entered a mini game along into it. + /// Each bot's entry is queued into its own MuHelper tick (see ), + /// because warping and effect-clearing mutate the bot's state. + /// + /// The party leader who entered the mini game. + /// The snapshot taken by before the entry. + /// The definition of the entered mini game. + /// The mini game instance the leader entered. + internal static void BringPartyBotsAlong(Player leader, IReadOnlyList bots, MiniGameDefinition definition, MiniGameContext miniGame) + { + foreach (var bot in bots) + { + bot.PendingBotActions.Enqueue(() => TryEnterAsync(bot, leader, definition, miniGame)); + } + } + + /// + /// Determines whether the bot passes the same entry restrictions EnterMiniGameAction + /// checks for a player: the event's character level bracket, the master class requirement and + /// the player killer restriction. Pure decision logic - exposed for unit tests. + /// + /// The bot which wants to follow its leader in. + /// The mini game definition. + /// The human-readable reason when the bot does not qualify. + internal static bool IsEligible(Player bot, MiniGameDefinition definition, out string reason) + { + var level = (int)(bot.Attributes?[Stats.Level] ?? 0); + if (level < definition.MinimumCharacterLevel) + { + reason = $"level {level} is below the minimum of {definition.MinimumCharacterLevel}"; + return false; + } + + if (level > definition.MaximumCharacterLevel) + { + reason = $"level {level} is above the maximum of {definition.MaximumCharacterLevel}"; + return false; + } + + if (definition.RequiresMasterClass && bot.SelectedCharacter?.CharacterClass?.IsMasterClass is not true) + { + reason = "it has not evolved into a master class yet"; + return false; + } + + if (!definition.ArePlayerKillersAllowedToEnter && bot.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage) + { + reason = "player killers cannot enter"; + return false; + } + + reason = string.Empty; + return true; + } + + private static async ValueTask TryEnterAsync(OfflinePlayer bot, Player leader, MiniGameDefinition definition, MiniGameContext miniGame) + { + if (bot.PlayerState.CurrentState != PlayerState.EnteredWorld + || !bot.IsAlive + || bot.CurrentMiniGame is not null) + { + return; + } + + if (!IsEligible(bot, definition, out var reason)) + { + // Like a player who cannot join the run: the bot says goodbye and goes back to its + // own hunting life instead of waiting at the gate. + bot.Logger.LogInformation( + "Bot '{Name}' cannot follow '{Leader}' into {Event} ({Reason}) and leaves the party.", + bot.Name, + leader.Name, + definition.Name, + reason); + if (bot.Party is { } party) + { + await party.KickMySelfAsync(bot).ConfigureAwait(false); + } + + return; + } + + if (definition.Entrance is not { } entrance) + { + return; + } + + var enterResult = await miniGame.TryEnterAsync(bot).ConfigureAwait(false); + if (enterResult != EnterResult.Success) + { + // Full or already closed - not the bot's fault; it stays in the party and waits for + // the leader outside, hunting normally. + bot.Logger.LogInformation( + "Bot '{Name}' could not follow '{Leader}' into {Event}: {Result}.", + bot.Name, + leader.Name, + definition.Name, + enterResult); + return; + } + + // Mirror of the player entry flow in EnterMiniGameAction, without ticket and entrance fee. + if (!definition.AllowParty && bot.Party is { } noPartyEventParty) + { + await noPartyEventParty.KickMySelfAsync(bot).ConfigureAwait(false); + } + + await bot.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false); + await bot.RemoveSummonAsync().ConfigureAwait(false); + await bot.WarpToAsync(entrance).ConfigureAwait(false); + bot.Logger.LogInformation( + "Bot '{Name}' follows '{Leader}' into {Event}.", + bot.Name, + leader.Name, + definition.Name); + } +} diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index f318aced5..92b88a0e3 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -305,6 +305,15 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } + // Inside a mini game event the whole normal routine below is suspended - the event + // dictates the pace, and every branch which could move the bot off the event map + // (shopping, revenge, warping, ground picking) must not run. + if (this._player.CurrentMiniGame is not null) + { + await this.EvaluateInsideMiniGameAsync(map).ConfigureAwait(false); + return; + } + // Party bookkeeping: answer a pending invitation from a player once its human-like delay // passed, and leave a party with a human again when the bot got bored (see BotPartyHandler). await BotPartyHandler.ProcessAsync(this._player).ConfigureAwait(false); @@ -626,9 +635,100 @@ private async ValueTask TryRevengeMarchAsync(GameMap map) return true; } + /// + /// The bot's routine while it takes part in a mini game event (Blood Castle, Devil Square, + /// Chaos Castle): fight whatever the event throws at it, keep up with the party leader on the + /// way through the course, and close in on the action when it moved away - nothing else. Death + /// and the event's end need no handling here: the engine respawns a dead bot at the map's + /// safezone like a player (which removes it from the event), and the event warps the remaining + /// participants out when it ends. + /// + private async ValueTask EvaluateInsideMiniGameAsync(GameMap map) + { + // Keep the drinking supplies topped up - an event without potions ends quickly. + this._player.PendingBotActions.Enqueue(() => this.EnsurePotionsAsync()); + + // Fight from wherever the bot stands; the combat handler engages the targets around it. + this._player.HuntingOrigin = this._player.Position; + + // The party boredom timer must not run down (let alone fire) mid-event; the window + // restarts once the event is over. + this._player.PartyBoredomAtUtc = null; + + if (this._player.IsWalking) + { + return; + } + + // Stay with the leader on the way through the event's course (the Blood Castle bridge) ... + if (this.GetPartyLeaderToFollow() is { } leader + && ReferenceEquals(leader.CurrentMap, map) + && this._player.GetDistanceTo(leader.Position) > FollowDistance) + { + await this.TravelTowardAsync(map, leader.Position).ConfigureAwait(false); + return; + } + + // ... and otherwise close in on the nearest opposition instead of idling at the entrance + // when the wave spawned (or the Chaos Castle crowd moved) beyond the combat range. + if (!map.GetAttackablesInRange(this._player.Position, TravelStopRange).Any(this.IsEventTarget) + && this.TryFindNearestEventTargetGround(map, out var ground)) + { + await this.TravelTowardAsync(map, ground).ConfigureAwait(false); + } + } + + /// + /// Whether the object is something this bot would fight inside a mini game event. Unlike in the + /// open world there is no safe-level filtering - the event's level bracket already matched the + /// bot, and the event dictates the opposition. Players count as targets only where the event + /// allows player killing (Chaos Castle, see ). + /// + private bool IsEventTarget(IAttackable attackable) + { + if (ReferenceEquals(attackable, this._player) + || !attackable.IsAlive + || attackable.IsAtSafezone()) + { + return false; + } + + return attackable switch + { + Monster monster => monster.Definition.ObjectKind == NpcObjectKind.Monster, + Player player => BotPvpRules.IsLegalPvpTarget(this._player, player), + _ => false, + }; + } + + /// + /// Finds the spot of a nearby opponent inside a mini game event, mirroring + /// (random among the two nearest, so many bots + /// don't dogpile the same target). + /// + private bool TryFindNearestEventTargetGround(GameMap map, out Point ground) + { + ground = default; + var position = this._player.Position; + var candidates = map.GetAttackablesInRange(position, MonsterSeekRadius) + .Where(this.IsEventTarget) + .OrderBy(a => a.GetDistanceTo(position)) + .Take(2) + .ToList(); + if (candidates.Count == 0) + { + return false; + } + + ground = candidates.SelectRandom()!.Position; + return true; + } + /// /// Gets the party leader this bot should follow, or null if the bot is solo, is the leader itself, - /// or the leader is currently not followable (dead, no map). + /// or the leader is currently not followable (dead, no map, or inside another mini game instance - + /// following into an event goes through the regular entry of , + /// never through a warp). /// private Player? GetPartyLeaderToFollow() { @@ -636,7 +736,8 @@ private async ValueTask TryRevengeMarchAsync(GameMap map) || party.PartyMaster is not Player leader || ReferenceEquals(leader, this._player) || !leader.IsAlive - || leader.CurrentMap is null) + || leader.CurrentMap is null + || !ReferenceEquals(leader.CurrentMiniGame, this._player.CurrentMiniGame)) { return null; } diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index ed7f3be52..815b9d257 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -61,9 +61,9 @@ internal static async ValueTask TryScheduleAcceptAsync(Player receiver, Pl return false; } - if (bot.IsOnShoppingTrip || bot.HasRevengeIntent) + if (bot.IsOnShoppingTrip || bot.HasRevengeIntent || bot.CurrentMiniGame is not null) { - // Busy - a player in the middle of an errand or a grudge would not group up either. + // Busy - a player in the middle of an errand, a grudge or an event would not group up either. return false; } diff --git a/src/GameLogic/Bots/BotPvpRules.cs b/src/GameLogic/Bots/BotPvpRules.cs index 2f6c8c4dc..8d4629326 100644 --- a/src/GameLogic/Bots/BotPvpRules.cs +++ b/src/GameLogic/Bots/BotPvpRules.cs @@ -39,6 +39,17 @@ public static class BotPvpRules /// true if attacking is free of PK consequences; otherwise, false. public static bool IsLegalPvpTarget(Player bot, Player target) { + // A running mini game with free player killing (Chaos Castle): every fellow participant + // is fair game - such kills never escalate the hero state (see Player.OnDeathAsync), the + // game's self-defense bookkeeping doesn't even track them. Gated on the running state, so + // bots don't swing at players during the countdown before the event starts. + if (!ReferenceEquals(bot, target) + && bot.CurrentMiniGame is { AllowPlayerKilling: true, IsEventRunning: true } miniGame + && ReferenceEquals(target.CurrentMiniGame, miniGame)) + { + return true; + } + // Outlaws are fair game for everyone - killing them never escalates the killer's state. if (target.SelectedCharacter?.State >= HeroState.PlayerKiller1stStage) { diff --git a/src/GameLogic/Bots/BotRevengePlugIn.cs b/src/GameLogic/Bots/BotRevengePlugIn.cs index d65378224..de1ef77e0 100644 --- a/src/GameLogic/Bots/BotRevengePlugIn.cs +++ b/src/GameLogic/Bots/BotRevengePlugIn.cs @@ -27,6 +27,7 @@ public ValueTask AttackableGotKilledAsync(IAttackable killed, IAttacker? killer) { if (killed is OfflinePlayer bot && bot.Account?.IsBot == true + && bot.CurrentMiniGame is null // a death in an event (Chaos Castle) is part of the game, not a wrong to avenge && killer is Player killerPlayer && killerPlayer is not OfflinePlayer && !ReferenceEquals(killerPlayer, killed)) diff --git a/src/GameLogic/Bots/BotSelfDefensePlugIn.cs b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs index 606704768..852eee707 100644 --- a/src/GameLogic/Bots/BotSelfDefensePlugIn.cs +++ b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs @@ -25,6 +25,7 @@ public void AttackableGotHit(IAttackable attackable, IAttacker attacker, HitInfo { if (attackable is OfflinePlayer bot && bot.Account?.IsBot == true + && bot.CurrentMiniGame is null // event fights (Chaos Castle) leave no grudge outside && attacker is Player aggressor && aggressor is not OfflinePlayer && !ReferenceEquals(aggressor, attackable)) diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 967a5db58..afb0a7842 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -393,22 +393,22 @@ private void RefreshTarget() if (this._currentTarget is null) { - var monsters = this.GetAttackableMonstersInHuntingRange().ToList(); + var targets = this.GetAttackableTargetsInHuntingRange().ToList(); // Choose 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. - var candidates = monsters + var candidates = targets .Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc) .OrderBy(m => m.GetDistanceTo(this._player)) .Take(2) .ToList(); this._currentTarget = candidates.SelectRandom(); - this._nearbyMonsterCount = monsters.Count; + this._nearbyMonsterCount = targets.Count; } else { - this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count(); + this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count(); } } @@ -424,6 +424,37 @@ private IEnumerable GetAttackableMonstersInHuntingRange() .Where(this.IsMonsterAttackable); } + /// + /// 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. + /// + private IEnumerable 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); @@ -467,6 +498,14 @@ private bool IsWithinSafeHuntLevel(Monster monster) 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); } @@ -506,15 +545,34 @@ await this._player.ForEachWorldObserverAsync( await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); } - // A player target (the self-defense aggressor) is hit by the area skill as well - but ONLY - // the target itself. 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. The - // legality re-check right at the strike closes the last race: the target was legal when it - // was picked, but the self-defense window may have run out in the meantime. - if (target is Player playerTarget && playerTarget.IsAlive && !playerTarget.IsAtSafezone() - && BotPvpRules.IsLegalPvpTarget(this._player, playerTarget)) + // 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 playerTargets; + if (this._player.CurrentMiniGame is { AllowPlayerKilling: true }) + { + playerTargets = this._player.CurrentMap? + .GetAttackablesInRange(target.Position, skill.Range) + .OfType() + .Where(p => !ReferenceEquals(p, this._player)) + ?? []; + } + else + { + playerTargets = target is Player playerTarget ? [playerTarget] : []; + } + + foreach (var player in playerTargets) { - await playerTarget.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); + if (player.IsAlive && !player.IsAtSafezone() + && BotPvpRules.IsLegalPvpTarget(this._player, player)) + { + await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); + } } } diff --git a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs index 01c2724be..2d180fc50 100644 --- a/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs +++ b/src/GameLogic/PlayerActions/MiniGames/EnterMiniGameAction.cs @@ -99,6 +99,10 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam var entrance = miniGameDefinition.Entrance ?? throw new InvalidOperationException("mini game entrance not defined"); var miniGame = await player.GameContext.GetMiniGameAsync(miniGameDefinition, player).ConfigureAwait(false); + // Snapshot before entering: an event which disallows parties (Chaos Castle) kicks the + // entering player out of its party below, losing the knowledge of who was going to follow. + var partyBots = Bots.BotMiniGameHandler.SnapshotPartyBots(player); + var enterResult = await miniGame.TryEnterAsync(player).ConfigureAwait(false); if (enterResult == EnterResult.Success) { @@ -125,6 +129,10 @@ public async ValueTask TryEnterMiniGameAsync(Player player, MiniGameType miniGam await player.RemoveSummonAsync().ConfigureAwait(false); await player.MagicEffectList.ClearEffectsAfterDeathAsync().ConfigureAwait(false); await player.WarpToAsync(entrance).ConfigureAwait(false); + + // The bots of the entering party leader follow them in (each checked against the same + // entry restrictions, no ticket needed - the leader's own ticket legitimizes the visit). + Bots.BotMiniGameHandler.BringPartyBotsAlong(player, partyBots, miniGameDefinition, miniGame); } else { diff --git a/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs new file mode 100644 index 000000000..2ef5ac513 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs @@ -0,0 +1,143 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.GameLogic.Offline; + +/// +/// Tests the entry decisions of - which party bots may follow +/// their human leader into a mini game event, and who is taken along at all. The actual entry +/// goes through the regular . +/// +[TestFixture] +public class BotMiniGameHandlerTest +{ + /// + /// The event's character level bracket is enforced in both directions. + /// + /// The bot's character level. + /// Whether the bot qualifies. + [TestCase(200, false)] + [TestCase(281, true)] + [TestCase(330, true)] + [TestCase(331, false)] + public async ValueTask EnforcesLevelBracketAsync(int level, bool expected) + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = level; + var definition = new MiniGameDefinition { MinimumCharacterLevel = 281, MaximumCharacterLevel = 330 }; + + var eligible = BotMiniGameHandler.IsEligible(bot, definition, out var reason); + + Assert.That(eligible, Is.EqualTo(expected)); + Assert.That(reason, expected ? Is.Empty : Is.Not.Empty); + } + + /// + /// An event for master classes only is not entered before the bot's master evolution. + /// + /// Whether the bot evolved into its master class. + /// Whether the bot qualifies. + [TestCase(false, false)] + [TestCase(true, true)] + public async ValueTask EnforcesMasterClassRequirementAsync(bool isMasterClass, bool expected) + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = 400; + bot.SelectedCharacter!.CharacterClass!.IsMasterClass = isMasterClass; + var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, RequiresMasterClass = true }; + + var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _); + + Assert.That(eligible, Is.EqualTo(expected)); + } + + /// + /// A player killer bot (should never happen, but the rule is mirrored from the player entry) + /// cannot enter events which disallow player killers. + /// + /// Whether the event allows player killers. + /// Whether the bot qualifies. + [TestCase(false, false)] + [TestCase(true, true)] + public async ValueTask EnforcesPlayerKillerRestrictionAsync(bool killersAllowed, bool expected) + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = 100; + bot.SelectedCharacter!.State = HeroState.PlayerKiller1stStage; + var definition = new MiniGameDefinition { MinimumCharacterLevel = 0, MaximumCharacterLevel = 400, ArePlayerKillersAllowedToEnter = killersAllowed }; + + var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _); + + Assert.That(eligible, Is.EqualTo(expected)); + } + + /// + /// Only the bots of the party whose MASTER enters are taken along - and only the bots, not the + /// human members. + /// + [Test] + public async ValueTask SnapshotTakesOnlyBotsOfTheEnteringMasterAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var leader = await CreateHumanAsync(gameContext, "Leader").ConfigureAwait(false); + var member = await CreateHumanAsync(gameContext, "Member").ConfigureAwait(false); + var bot1 = await CreateBotAsync(gameContext, "BotOne").ConfigureAwait(false); + var bot2 = await CreateBotAsync(gameContext, "BotTwo").ConfigureAwait(false); + + var party = gameContext.PartyManager.CreateParty(); + await party.AddAsync(leader).ConfigureAwait(false); + await party.AddAsync(member).ConfigureAwait(false); + await party.AddAsync(bot1).ConfigureAwait(false); + await party.AddAsync(bot2).ConfigureAwait(false); + + var fromLeader = BotMiniGameHandler.SnapshotPartyBots(leader); + var fromMember = BotMiniGameHandler.SnapshotPartyBots(member); + + Assert.That(fromLeader, Is.EquivalentTo(new[] { bot1, bot2 })); + Assert.That(fromMember, Is.Empty); + } + + /// + /// A player without a party (or a bot, however it would get here) takes nobody along. + /// + [Test] + public async ValueTask SnapshotIsEmptyWithoutPartyAsync() + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var solo = await CreateHumanAsync(gameContext, "Solo").ConfigureAwait(false); + var bot = await CreateBotAsync(gameContext, "Bot").ConfigureAwait(false); + + Assert.That(BotMiniGameHandler.SnapshotPartyBots(solo), Is.Empty); + Assert.That(BotMiniGameHandler.SnapshotPartyBots(bot), Is.Empty); + } + + private static async ValueTask CreateBotAsync(IGameContext gameContext, string name = "Bot") + { + var bot = await PlayerTestHelper.CreateOfflineLevelingPlayerAsync(gameContext).ConfigureAwait(false); + await bot.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + bot.SelectedCharacter!.Name = name; + bot.IsAlive = true; + bot.Account!.IsBot = true; + return bot; + } + + private static async ValueTask CreateHumanAsync(IGameContext gameContext, string name) + { + var player = await PlayerTestHelper.CreatePlayerAsync(gameContext).ConfigureAwait(false); + await player.PlayerState.TryAdvanceToAsync(PlayerState.EnteredWorld).ConfigureAwait(false); + player.SelectedCharacter!.Name = name; + player.IsAlive = true; + return player; + } +} From fce4773e6e7b0e4ac99c3b91f1e6a8385862076c Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 13 Jul 2026 13:19:08 +0200 Subject: [PATCH 39/60] Fix the bot trade dead ends, treasure hoarding and travel churn Six findings from an hour of watching five bots (one per class) on a 1100-bot server: The merchant for a shopping trip is now picked from the map's LIVE objects instead of the spawn configuration: wandering merchants exist in the configuration but are only spawned now and then, so a bot kept walking to a configured but unspawned merchant, waited at an empty spot and silently gave up - every ten minutes, forever. The formerly silent failure paths of the trade now log their reason, and a completed visit is logged even when nothing was sold or bought, so an audit can tell "went and had nothing to do" from a failed trip. A bot no longer hoards every excellent or ancient piece: it cannot trade its treasures away like a human, so it only picks up what it can actually wear as an upgrade, and outgrown pieces count as sellable junk (jewels stay protected). Without this the backpack silted up within an hour and the loot pickup stopped entirely. The human offline helper keeps hoarding - its owner sorts the treasure out later. On a map without any merchant (the Dungeon has no town) the bot now warps to its class home town for the shopping trip, like a player pulling a town scroll - before, staying there starved its logistics completely. A trip aborted for lack of a route also backs off for the full shopping cooldown instead of retrying twice a minute. An unreachable hunting ground now waits out the empty-ground grace before another one is picked; re-picking immediately turned a pocket of unreachable picks into a full-tick churn hammering the path finder. The buff handler takes an eager snapshot of the active magic effects before scanning them (with a last-resort catch): the lazy enumeration raced the effect expiry timers regularly at scale - hundreds of caught exceptions per hour on a full server. --- src/GameLogic/Bots/BotNavigator.cs | 48 +++++++++--- src/GameLogic/Bots/BotShoppingHandler.cs | 85 +++++++++++----------- src/GameLogic/Offline/BuffHandler.cs | 17 ++++- src/GameLogic/Offline/ItemPickupHandler.cs | 16 ++-- 4 files changed, 107 insertions(+), 59 deletions(-) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 92b88a0e3..afb92efd4 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -489,10 +489,12 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) { if (!await this.TravelTowardAsync(map, this._destination).ConfigureAwait(false)) { - // Impossible route: pick another ground now (proximity-weighted toward nearby, reachable - // ones) instead of standing still re-issuing an impossible walk. - this._emptyGroundSince = null; - await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); + // Impossible route: drop the destination and wait out the empty-ground grace before + // picking another one. Re-picking immediately turned a pocket of unreachable picks + // into a full-tick churn - a new far-away ground every second, hammering the path + // finder without the bot ever moving. + this._hasDestination = false; + this._emptyGroundSince ??= DateTime.UtcNow; } return; @@ -534,9 +536,13 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) { if (!await this.TravelTowardAsync(map, target).ConfigureAwait(false)) { - // No way to the merchant from here - give up this trip. + // No way to the merchant from here - give up this trip and don't retry every + // check interval (an unreachable merchant stays unreachable for a while; a bot + // retrying twice a minute wastes its hunting time on futile marches). + this._player.Logger.LogInformation("Bot '{Name}' gives up its shopping trip: no route to the merchant at {Target}.", this._player.Name, target); this._shoppingTarget = null; this._player.IsOnShoppingTrip = false; + this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCooldown; } return true; @@ -564,12 +570,31 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) } this._nextShoppingCheckUtc = DateTime.UtcNow + ShoppingCheckInterval; - if (!BotShoppingHandler.NeedsShopping(this._player) - || BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition) + if (!BotShoppingHandler.NeedsShopping(this._player)) { return false; } + if (BotShoppingHandler.FindMerchantPosition(map) is not { } merchantPosition) + { + // No merchant lives on this map at all (the Dungeon has no town). Shopping here would + // starve the bot's logistics for as long as it stays - no potion restock, no selling, a + // slowly silting backpack. Like a player pulling a town scroll, it warps to its class + // home town and shops there; the better-map logic takes it back hunting afterwards. + if (this.TryGetHomeEscapeGate(out var homeGate, out var homeMap, out _)) + { + this._travelPath = null; + this._hasDestination = false; + this._lastWarpUtc = DateTime.UtcNow; + this._nextShoppingCheckUtc = DateTime.UtcNow; // start the trip on the new map right away + this._player.Logger.LogInformation("Bot '{Name}' warps home to {Map} for a shopping trip - no merchant on its map.", this._player.Name, homeMap.Name); + await this._player.WarpToAsync(homeGate).ConfigureAwait(false); + return true; + } + + return false; + } + // Merchants stand in town: when out in the field, warp to the map's safezone first (like using // a town portal scroll), then walk over to the merchant. if (!inSafezone @@ -754,8 +779,9 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) { if (!ReferenceEquals(leader.CurrentMap, map)) { + ExitGate? warpListGate = null; if (leader.CurrentMap is { } targetMap - && !this.TryGetLegalWarpGate(targetMap.Definition, out _) + && !this.TryGetLegalWarpGate(targetMap.Definition, out warpListGate) && targetMap.Definition.Number != this._player.SelectedCharacter?.CharacterClass?.HomeMap?.Number) { // The leader moved to a map the bot's plain character level cannot legally enter @@ -774,9 +800,13 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) return true; } + // Prefer a spawn gate of the leader's map; maps without one (the Dungeon has no town) + // fall back to the warp list gate - the same spot the leader warped to. Without the + // fallback a follower could neither warp after its leader nor hunt (following consumed + // its ticks), and only the stuck watchdog kept it twitching between hunting grounds. if (DateTime.UtcNow - this._lastWarpUtc >= FollowWarpCooldown && leader.CurrentMap is { } leaderMap - && leaderMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() is { } leaderGate) + && (leaderMap.Definition.ExitGates.Where(g => g.IsSpawnGate).SelectRandom() ?? warpListGate) is { } leaderGate) { this._lastWarpUtc = DateTime.UtcNow; this._hasDestination = false; diff --git a/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs index 975f0ace7..86bf0f05a 100644 --- a/src/GameLogic/Bots/BotShoppingHandler.cs +++ b/src/GameLogic/Bots/BotShoppingHandler.cs @@ -71,7 +71,7 @@ public static bool NeedsShopping(OfflinePlayer player) } var freeSlots = inventory.FreeSlots.Count(); - if (freeSlots < FreeSlotPressure && inventory.Items.Any(IsSellableJunk)) + if (freeSlots < FreeSlotPressure && inventory.Items.Any(i => IsSellableJunk(player, i))) { return true; } @@ -85,33 +85,21 @@ public static bool NeedsShopping(OfflinePlayer player) } /// - /// Finds the position of a merchant NPC on the map (from the map's spawn configuration; merchants - /// are stationary), preferring one which sells potions. + /// Finds the position of a merchant NPC on the map, preferring one which sells potions. The + /// search runs over the map's LIVE objects, not the spawn configuration: wandering merchants + /// exist in the configuration but are only spawned now and then (their spawn trigger is not + /// automatic) - a bot walking to a configured but unspawned merchant would wait at an empty + /// spot and give up its trip, forever. /// /// The game map. public static Point? FindMerchantPosition(GameMap map) { - MonsterSpawnArea? best = null; - foreach (var spawn in map.Definition.MonsterSpawns) - { - if (spawn.MonsterDefinition is not { ObjectKind: NpcObjectKind.PassiveNpc } definition - || definition.MerchantStore is not { Items.Count: > 0 } store) - { - continue; - } - - var sellsPotions = store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6); - if (best is null || sellsPotions) - { - best = spawn; - if (sellsPotions) - { - break; - } - } - } - - return best is null ? null : new Point(best.X1, best.Y1); + // Covers the whole 256x256 map from its center. + var merchants = map.GetNpcsInRange(new Point(128, 128), 256) + .Where(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 }) + .ToList(); + var best = merchants.FirstOrDefault(m => SellsPotions(m.Definition.MerchantStore!)) ?? merchants.FirstOrDefault(); + return best?.Position; } /// @@ -128,19 +116,23 @@ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap .FirstOrDefault(n => n.Definition is { ObjectKind: NpcObjectKind.PassiveNpc, MerchantStore.Items.Count: > 0 }); if (merchant is null || player.Inventory is not { } inventory) { + // Not silent: exactly this case hid the wandering-merchant bug (a configured but + // unspawned merchant) for a long time. + player.Logger.LogInformation("Bot '{Name}' found no merchant near {Position} and gives up the trip.", player.Name, merchantPosition); return false; } await TalkAction.TalkToNpcAsync(player, merchant).ConfigureAwait(false); if (player.OpenedNpc is null) { + player.Logger.LogInformation("Bot '{Name}' could not open the dialog of '{Merchant}'.", player.Name, merchant.Definition.Designation); return false; } try { var soldCount = 0; - foreach (var junk in inventory.Items.Where(IsSellableJunk).ToList()) + foreach (var junk in inventory.Items.Where(i => IsSellableJunk(player, i)).ToList()) { await SellAction.SellItemAsync(player, junk.ItemSlot).ConfigureAwait(false); soldCount++; @@ -171,16 +163,15 @@ public static async ValueTask TryTradeAsync(OfflinePlayer player, GameMap } } - if (soldCount > 0 || boughtCount > 0) - { - player.Logger.LogInformation( - "Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.", - player.Name, - merchant.Definition.Designation, - soldCount, - boughtCount, - player.Money); - } + // Logged even for a 0/0 visit: an audit must be able to tell "went and had nothing to + // do" from a silently failed trip. + player.Logger.LogInformation( + "Bot '{Name}' traded with '{Merchant}': sold {Sold} item(s), bought {Bought} potion stack(s), {Money} zen left.", + player.Name, + merchant.Definition.Designation, + soldCount, + boughtCount, + player.Money); } finally { @@ -214,10 +205,12 @@ private static int GetPotionCharges(Player player, byte potionNumber) } /// - /// Sellable junk: plain unequipped gear in the backpack - not potions/ammunition, not jewels and - /// not excellent/ancient pieces (those stay as the bot's "wealth", like a player would keep them). + /// Sellable junk: unequipped gear in the backpack - not potions/ammunition and not jewels. An + /// excellent or ancient piece is junk too unless it is an upgrade the bot is about to wear: + /// unlike a player, a bot cannot trade its treasures away, so hoarding them only silts up the + /// backpack until the loot pickup stops entirely. /// - private static bool IsSellableJunk(Item item) + private static bool IsSellableJunk(OfflinePlayer player, Item item) { if (item.ItemSlot < InventoryConstants.EquippableSlotsCount || item.Definition is not { } definition) @@ -235,8 +228,18 @@ private static bool IsSellableJunk(Item item) return false; } - var isExcellentOrAncient = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent) - || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0); - return !isExcellentOrAncient; + var isTreasure = item.ItemOptions.Any(o => o.ItemOption?.OptionType == DataModel.Configuration.Items.ItemOptionTypes.Excellent) + || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0); + if (isTreasure) + { + return !BotEquipmentHandler.IsUpgradeFor(player, item); + } + + return true; + } + + private static bool SellsPotions(DataModel.Entities.ItemStorage store) + { + return store.Items.Any(i => i.Definition?.Group == 14 && i.Definition.Number is 3 or 6); } } diff --git a/src/GameLogic/Offline/BuffHandler.cs b/src/GameLogic/Offline/BuffHandler.cs index 3beaeec99..bd2ff8abf 100644 --- a/src/GameLogic/Offline/BuffHandler.cs +++ b/src/GameLogic/Offline/BuffHandler.cs @@ -278,8 +278,21 @@ private bool IsEffectActive(IAttackable target, SkillEntry skillEntry) 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() diff --git a/src/GameLogic/Offline/ItemPickupHandler.cs b/src/GameLogic/Offline/ItemPickupHandler.cs index 7acba4e51..ecc6c6479 100644 --- a/src/GameLogic/Offline/ItemPickupHandler.cs +++ b/src/GameLogic/Offline/ItemPickupHandler.cs @@ -120,14 +120,16 @@ private bool ShouldPickUp(Item item) 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; - } - - if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent)) - { - 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.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item)) From 013dddf241946837f0f114273239ae29ed5d03f3 Mon Sep 17 00:00:00 2001 From: nolt Date: Mon, 13 Jul 2026 19:56:22 +0200 Subject: [PATCH 40/60] Fix the bot equipment loop, its stuck casters and the stop leaks A code review of the whole bot plug-in, after the hour-long runs with 1100 bots, found a family of defects worth a round of its own: The gear swap no longer loops. A candidate the engine refuses to equip - a two-handed weapon while the other hand is occupied - left the bot with an empty hand: it put the old piece back on the next pass and started over, hundreds of swaps an hour, fighting unarmed half of the time. The bot now plans the swap the way the engine judges it: the hand rule moved out of MoveItemAction into a shared extension (no second copy to drift), a two-handed weapon takes the blocking piece along into the swap and only wins if it beats what it displaces, and a rejected equip puts everything back where it was. Weapons only go into the main hand and shields into the off-hand, so bots stop dual-wielding the junk they are qualified for. The casters were stuck on the starter maps: the safety check read the wizardry BONUS channel (a staff's rise) instead of the wizardry damage itself, so an energy build looked like it had no offense at all and never qualified for a better map. It also assumed that every monster swing lands, while an agility build tanks by dodging - the expected hit now accounts for the hit chance (with a conservative floor), which is what a squishy build actually survives on. The safe-hit threshold itself is unchanged. A Magic Gladiator specced into energy fought with a sword: the weapon style followed the class's base attributes instead of the bot's build. Both the starter gear and the later upgrades now ask the same build-aware rule. Stopped bots were never disposed - the presence rotation and the master restart left their persistence context (with the whole tracked account graph) behind, around the clock. They are now torn down like the offline sessions of the player helper. The navigator's timer callback read the token of an already disposed cancellation source, which would throw on a thread pool thread and take the process down with it; it captures the token once now, like the helper tick. Further findings of the review: - The bots judged maps and hunting grounds by spawns which only exist while an event runs (golden monsters, invasions), so they travelled to grounds that are empty most of the day - and thought their own map held monsters they could never find there. - The special characters were judged by the regular level bracket of the mini game events, which kicked a qualified Magic Gladiator out of its leader's party. - The trade at the merchant and the master evolution mutated the character from outside the bot's tick, racing its combat with a shared persistence context; both go through the pending-actions queue now, like every other bot-initiated mutation. - Followers waited for their leader to settle on a map before warping after him - a leader passing through town used to drag his whole party along. - The maintenance pass guards against re-entering itself; the engine fires the periodic tasks without awaiting the previous run. - The emergency potion refill respects the stack size of the item definition. The 255-charge "potion" it created before was an item the game cannot produce: that potion kind never looked low again, so the bot stopped buying it. - A jewel or gear swap looks for a backpack hole of the item's SIZE, not just any free slot. - A bot account which cannot be deleted during a bot reset says so instead of silently coming back to life. The offline session of a real player keeps behaving exactly as it did: the reaction delay and the target spread, which only serve to make bots look human, are theirs alone now. --- src/GameLogic/Bots/BotEquipmentHandler.cs | 297 +++++++++++------- src/GameLogic/Bots/BotFeaturePlugIn.cs | 94 ++++-- src/GameLogic/Bots/BotGenerator.cs | 47 ++- src/GameLogic/Bots/BotJewelHandler.cs | 7 +- src/GameLogic/Bots/BotManager.cs | 43 ++- src/GameLogic/Bots/BotMiniGameHandler.cs | 17 +- src/GameLogic/Bots/BotNavigator.cs | 153 ++++++--- src/GameLogic/Bots/BotPlayer.cs | 9 + src/GameLogic/Bots/BotProgression.cs | 22 +- src/GameLogic/ItemExtensions.cs | 31 ++ src/GameLogic/Offline/CombatHandler.cs | 78 +++-- .../PlayerActions/Items/MoveItemAction.cs | 13 +- .../BotEquipmentHandlerTest.cs | 177 +++++++++++ .../BotMiniGameHandlerTest.cs | 32 ++ 14 files changed, 759 insertions(+), 261 deletions(-) create mode 100644 tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs diff --git a/src/GameLogic/Bots/BotEquipmentHandler.cs b/src/GameLogic/Bots/BotEquipmentHandler.cs index 0206db28d..9f6bc91e9 100644 --- a/src/GameLogic/Bots/BotEquipmentHandler.cs +++ b/src/GameLogic/Bots/BotEquipmentHandler.cs @@ -11,17 +11,23 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// /// Lets a bot progress its equipment like a real player: dropped gear is evaluated before pickup /// (see , used by the offline ), and looted -/// upgrades are periodically equipped, with the replaced piece dropped to the ground so the backpack -/// does not silt up with junk. All moves go through the regular , which -/// enforces the same class- and stat-requirements as for a human player. +/// upgrades are periodically equipped; the replaced piece goes into the backpack and is sold on the next +/// shopping trip, so the backpack does not silt up with junk. All moves go through the regular +/// , which enforces the same class- and stat-requirements as for a human +/// player - including which hand a weapon needs (see ). /// internal static class BotEquipmentHandler { /// A candidate must beat the equipped piece by at least this score margin to be worth swapping. private const int UpgradeScoreMargin = 1; + /// The highest item group which is a weapon (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff). + private const byte LastWeaponGroup = 5; + + /// The item group of the shields. + private const byte ShieldGroup = 6; + private static readonly MoveItemAction MoveAction = new(); - private static readonly DropItemAction DropAction = new(); /// /// Determines whether the dropped item would be an upgrade over the bot's currently equipped gear, @@ -31,48 +37,17 @@ internal static class BotEquipmentHandler /// The dropped item to evaluate. public static bool IsUpgradeFor(Player player, Item item) { - if (item.Definition is not { } definition - || player.SelectedCharacter?.CharacterClass is not { } characterClass - || player.Inventory is not { } inventory) - { - return false; - } - - if (!IsWearableCandidate(definition, characterClass) - || !player.CompliesRequirements(item)) - { - return false; - } - - var candidateScore = Score(item); - foreach (var slot in definition.ItemSlot!.ItemSlots) - { - var equipped = inventory.GetItem((byte)slot); - if (equipped?.Definition?.IsAmmunition == true) - { - // Never displace the ammunition (an archer's arrows) - the bow would stop working. - continue; - } - - if (equipped is null || Score(equipped) + UpgradeScoreMargin <= candidateScore) - { - return true; - } - } - - return false; + return TryPlanSwap(player, item) is not null; } /// - /// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece is - /// dropped to the ground (a real player would leave the outgrown junk behind too), unless it is - /// something valuable (excellent/ancient), which stays in the backpack. + /// Scans the bot's backpack for equippable upgrades and puts the best one on; the replaced piece + /// stays in the backpack, where the next shopping trip sells it. /// /// The bot player whose backpack is scanned. public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player) { - if (player.Inventory is not { } inventory - || player.SelectedCharacter?.CharacterClass is not { } characterClass) + if (player.Inventory is not { } inventory) { return; } @@ -84,109 +59,199 @@ public static async ValueTask TryEquipUpgradesAsync(OfflinePlayer player) foreach (var item in backpackItems) { - if (item.Definition is not { } definition - || !IsWearableCandidate(definition, characterClass) - || !player.CompliesRequirements(item)) + if (TryPlanSwap(player, item) is not { } plan) { continue; } - var candidateScore = Score(item); + if (await TryApplySwapAsync(player, inventory, item, plan).ConfigureAwait(false)) + { + // One swap per pass keeps the work per tick small; the next pass picks up the rest. + return; + } + } + } + + /// + /// Puts the planned piece on: the gear it replaces goes to the backpack first (an equip only works + /// into a free slot), then the candidate is equipped through the regular . + /// If the engine refuses the equip after all - the requirements are checked against the TOTAL stats, + /// which drop as soon as the old piece with its bonuses comes off - everything moved so far is put + /// back on. Without that rollback the bot ended up with an empty slot, re-equipped the old piece on + /// its next pass and started over: hundreds of swaps per hour, fighting without a weapon half of the + /// time. + /// + /// true if the candidate is now equipped. + private static async ValueTask TryApplySwapAsync(OfflinePlayer player, IStorage inventory, Item item, EquipSwap plan) + { + var undo = new List<(Item Item, byte EquipSlot)>(2); + foreach (var removed in plan.Removed) + { + if (inventory.CheckInvSpace(removed) is not { } freeSlot) + { + // No room of the item's SIZE in the backpack (a 2x3 armor needs a 2x3 hole). + await RollbackAsync(player, undo).ConfigureAwait(false); + return false; + } - // Prefer an empty qualified slot; otherwise replace the weakest equipped piece it beats. - byte? targetSlot = null; - Item? replaced = null; - foreach (var slot in definition.ItemSlot!.ItemSlots) + var equipSlot = removed.ItemSlot; + await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(equipSlot) is not null) { - var equipped = inventory.GetItem((byte)slot); - if (equipped?.Definition?.IsAmmunition == true) - { - // Never displace the ammunition (an archer's arrows) - the bow would stop working. - continue; - } + player.Logger.LogDebug("Bot '{Name}' could not take off '{Item}' for a swap.", player.Name, removed); + await RollbackAsync(player, undo).ConfigureAwait(false); + return false; + } - if (equipped is null) - { - targetSlot = (byte)slot; - replaced = null; - break; - } + undo.Add((removed, equipSlot)); + } - if (Score(equipped) + UpgradeScoreMargin <= candidateScore - && (replaced is null || Score(equipped) < Score(replaced))) - { - targetSlot = (byte)slot; - replaced = equipped; - } + await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, plan.Slot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(plan.Slot) != item) + { + player.Logger.LogDebug("Bot '{Name}' could not equip '{Item}' - putting its old gear back on.", player.Name, item); + await RollbackAsync(player, undo).ConfigureAwait(false); + return false; + } + + player.Logger.LogInformation( + "Bot '{Name}' equipped '{New}'{Replaced}.", + player.Name, + item, + plan.Removed.Count == 0 ? string.Empty : $" (replacing {string.Join(", ", plan.Removed.Select(r => $"'{r}'"))})"); + + // The outgrown gear stays in the backpack and is SOLD on the next shopping trip (see + // BotShoppingHandler.IsSellableJunk): dropping it on the ground littered the hunting grounds + // with the bots' hand-me-downs, which the bots then picked up again - including the bot which + // had just dropped the piece, whose persistence context still tracked it (an entity conflict on + // the pickup). Selling it also feeds the Zen the bot restocks its potions with. + return true; + } + + private static async ValueTask RollbackAsync(OfflinePlayer player, List<(Item Item, byte EquipSlot)> undo) + { + foreach (var (item, equipSlot) in undo) + { + await MoveAction.MoveItemAsync(player, item.ItemSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false); + if (player.Inventory?.GetItem(equipSlot) != item) + { + player.Logger.LogWarning("Bot '{Name}' could not put '{Item}' back on into slot {Slot}.", player.Name, item, equipSlot); } + } + } + + /// + /// Plans how the item could be worn: into which slot, and which equipped pieces it would replace. + /// Returns null when the bot would not (or could not) wear it - which is exactly what makes an + /// item worth picking up from the ground, so the pickup handler asks the same question through + /// . Beside the piece in the target slot, a two-handed weapon also replaces + /// whatever blocks the other hand: the engine refuses to equip it otherwise (see + /// ), and a bot which does not plan for that + /// keeps trying (and failing) to put it on forever. + /// + private static EquipSwap? TryPlanSwap(Player player, Item item) + { + if (item.Definition is not { } definition + || player.SelectedCharacter?.CharacterClass is not { } characterClass + || player.Inventory is not { } inventory + || !IsWearableCandidate(player, definition, characterClass) + || !player.CompliesRequirements(item)) + { + return null; + } - if (targetSlot is not { } equipSlot) + var candidateScore = Score(item); + EquipSwap? best = null; + var bestReplacedScore = 0; + foreach (var slot in GetTargetSlots(definition)) + { + var equipped = inventory.GetItem(slot); + if (equipped?.Definition?.IsAmmunition == true) { + // Never displace the ammunition (an archer's arrows) - the bow would stop working. continue; } - if (replaced is not null) + var removed = new List(2); + if (equipped is not null) { - // Make room: move the old piece to a free backpack slot first. - if (FindFreeBackpackSlot(inventory) is not { } freeSlot) - { - continue; - } + removed.Add(equipped); + } - await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false); - if (inventory.GetItem(equipSlot) is not null) + if (definition.ConflictsWithEquippedHands(inventory, slot)) + { + // Only the main hand resolves such a conflict: taking the two-handed weapon off to fit a + // shield into the other hand would disarm the bot. + if (slot != InventoryConstants.LeftHandSlot + || inventory.GetItem(InventoryConstants.RightHandSlot) is not { } blocking) { - // The unequip was rejected - don't try to force the swap. continue; } + + removed.Add(blocking); } - var fromSlot = item.ItemSlot; - await MoveAction.MoveItemAsync(player, fromSlot, Storages.Inventory, equipSlot, Storages.Inventory).ConfigureAwait(false); - if (inventory.GetItem(equipSlot) != item) + var replacedScore = removed.Sum(Score); + if (removed.Count > 0 && replacedScore + UpgradeScoreMargin > candidateScore) { - // Equip rejected (e.g. requirements after all) - leave everything as is. continue; } - player.Logger.LogInformation( - "Bot '{Name}' equipped '{New}'{Replaced}.", - player.Name, - item, - replaced is null ? string.Empty : $" (replacing '{replaced}')"); - - if (replaced is not null && !IsWorthKeeping(replaced)) + // Prefer the cheapest swap: an empty slot beats replacing gear, and among occupied slots the + // weakest gear goes first (this is what spreads rings over both ring slots). + if (best is null || replacedScore < bestReplacedScore) { - try - { - await DropAction.DropItemAsync(player, replaced.ItemSlot, player.Position).ConfigureAwait(false); - } - catch (InvalidOperationException ex) - { - // E.g. the map ran out of object ids for another drop - not worth failing the swap - // over; the outgrown piece simply stays in the backpack (plenty of room with the - // extended inventory) until a later pass or forever. - player.Logger.LogDebug(ex, "Bot '{Name}' could not drop replaced item '{Item}'.", player.Name, replaced); - } + best = new EquipSwap(slot, removed); + bestReplacedScore = replacedScore; } + } - // One swap per pass keeps the work per tick small; the next pass picks up the rest. - return; + return best; + } + + /// + /// The slots the bot considers for this piece: a weapon only goes into the main hand and a shield + /// only into the off-hand - a bot filling its off-hand with a second (junk) weapon it happens to be + /// qualified for is neither useful nor a sight any real character offers. Everything else may go into + /// any of its qualified slots (rings have two). + /// + private static IEnumerable GetTargetSlots(ItemDefinition definition) + { + var slots = definition.ItemSlot!.ItemSlots.Select(s => (byte)s).ToList(); + if (definition.Group <= LastWeaponGroup && slots.Contains(InventoryConstants.LeftHandSlot)) + { + return [InventoryConstants.LeftHandSlot]; + } + + if (definition.Group == ShieldGroup && slots.Contains(InventoryConstants.RightHandSlot)) + { + return [InventoryConstants.RightHandSlot]; } + + return slots; } /// /// Whether the item is gear this bot would wear at all: an equippable, class-qualified piece which - - /// if it is a weapon - matches the class's fighting style (an elf only considers bows, a wizard only - /// staves), so bots don't fill their off-hand with random qualified junk like a Small Axe. + /// if it is a weapon - matches the fighting style of the bot's build (an elf only considers bows, a + /// caster only staves), so bots don't fill their hands with random qualified junk like a Small Axe. /// - private static bool IsWearableCandidate(ItemDefinition definition, CharacterClass characterClass) + private static bool IsWearableCandidate(Player player, ItemDefinition definition, CharacterClass characterClass) { - const byte lastWeaponGroup = 5; - return definition.ItemSlot is { ItemSlots.Count: > 0 } - && !definition.IsAmmunition - && definition.QualifiedCharacters.Contains(characterClass) - && (definition.Group > lastWeaponGroup || BotProgression.IsPreferredWeaponGroup(characterClass, (byte)definition.Group)); + if (definition.ItemSlot is not { ItemSlots.Count: > 0 } + || definition.IsAmmunition + || !definition.QualifiedCharacters.Contains(characterClass)) + { + return false; + } + + if (definition.Group > LastWeaponGroup) + { + return true; + } + + var resetMeta = BotResetHandler.GetResetConfiguration(player.GameContext) is not null; + return BotProgression.IsPreferredWeaponGroup(characterClass, player.SelectedCharacter!.Name, resetMeta, (byte)definition.Group); } /// @@ -210,17 +275,9 @@ private static int Score(Item item) return score; } - private static bool IsWorthKeeping(Item item) - { - return item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent) - || item.ItemSetGroups.Any(s => s.AncientSetDiscriminator != 0); - } - - private static byte? FindFreeBackpackSlot(IStorage inventory) - { - return inventory.FreeSlots - .Where(s => s >= InventoryConstants.EquippableSlotsCount) - .Cast() - .FirstOrDefault(); - } + /// + /// A planned equip: the slot the candidate goes into, and the equipped pieces which have to come off + /// for it (the gear in the target slot, plus the other hand's item when a two-handed weapon needs it). + /// + private sealed record EquipSwap(byte Slot, IReadOnlyList Removed); } diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 090ca1be0..ffec3bba6 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -63,6 +63,12 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus /// private int _startupState; + /// + /// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in parallel + /// (see ). + /// + private int _maintenanceRunning; + /// public BotConfiguration? Configuration { get; set; } @@ -228,10 +234,11 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) return; } - this._nextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; - - var configuration = this.Configuration; - if (configuration?.Enabled != true) + // The engine fires the periodic tasks every second WITHOUT awaiting the previous run, so a pass + // which takes longer than its interval (spawning a bot loads a whole account from the database) + // would otherwise overlap with itself: two passes restarting the same bot, rotating the presence + // twice, forming parties in parallel. One pass at a time - like the startup below. + if (Interlocked.CompareExchange(ref this._maintenanceRunning, 1, 0) != 0) { return; } @@ -239,6 +246,14 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); try { + this._nextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; + + var configuration = this.Configuration; + if (configuration?.Enabled != true) + { + return; + } + await this.RespawnPendingAsync(gameContext).ConfigureAwait(false); await this.EvolveDueMastersAsync(gameContext, logger).ConfigureAwait(false); @@ -257,6 +272,10 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) { logger.LogError(ex, "Bot maintenance failed."); } + finally + { + Interlocked.Exchange(ref this._maintenanceRunning, 0); + } } /// @@ -270,11 +289,6 @@ private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger l { foreach (var bot in this._botManager.Bots) { - if (!BotMasterHandler.IsMasterEvolutionDue(bot)) - { - continue; - } - // Not while it hunts with a human (the restart would desert the group), sits in an NPC // dialog or lies dead - like a due reset, the evolution simply happens on a later pass. if (BotPartyHandler.HasHumanCompanion(bot) @@ -284,28 +298,58 @@ private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger l continue; } - // Captured before the restart - the disposed bot loses its account and character. - var loginName = bot.Account?.LoginName; - var characterSlot = bot.SelectedCharacter?.CharacterSlot; - try + if (bot.AwaitsMasterRestart) + { + await this.RestartEvolvedBotAsync(gameContext, bot, logger).ConfigureAwait(false); + continue; + } + + if (BotMasterHandler.IsMasterEvolutionDue(bot)) { - if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false) - && !await this._botManager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) - && loginName is not null - && characterSlot is { } slot) + // The class change and its save go through the bot's own tick, like every other + // bot-initiated mutation (see OfflinePlayer.PendingBotActions): running them from the + // maintenance pass would write the character through the same persistence context the + // combat tick is using at that very moment. The restart follows on the next pass - + // it must NOT happen from within one of the bot's own timer callbacks. + bot.PendingBotActions.Enqueue(async () => { - // The evolution is persisted; only the presence is at risk. RespawnPendingAsync - // drops the entry when the bot is (still or again) online, so a kept-alive old - // instance or a rotation comeback doesn't get doubled. - logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name); - this._pendingRespawns.Enqueue((loginName, slot)); - } + if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false)) + { + bot.AwaitsMasterRestart = true; + } + }); } - catch (Exception ex) + } + } + + /// + /// Gives the freshly evolved bot the "relog" it needs: the master class's base attributes (master + /// experience rate, master points per level) and the master level stat are only mounted when a + /// character enters the world (see ). + /// + private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, BotPlayer bot, ILogger logger) + { + // Captured before the restart - the disposed bot loses its account and character. + var loginName = bot.Account?.LoginName; + var characterSlot = bot.SelectedCharacter?.CharacterSlot; + bot.AwaitsMasterRestart = false; + try + { + if (!await this._botManager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) + && loginName is not null + && characterSlot is { } slot) { - logger.LogError(ex, "Failed to evolve bot '{Name}' into its master class.", bot.Name); + // The evolution is persisted; only the presence is at risk. RespawnPendingAsync + // drops the entry when the bot is (still or again) online, so a kept-alive old + // instance or a rotation comeback doesn't get doubled. + logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name); + this._pendingRespawns.Enqueue((loginName, slot)); } } + catch (Exception ex) + { + logger.LogError(ex, "Failed to restart bot '{Name}' after its master evolution.", bot.Name); + } } /// diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index ccf2c1cf9..86a87d3b6 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -193,22 +193,30 @@ public async ValueTask DeleteAllBotsAsync(CancellationToken cancellationTok } var bots = page.Where(a => a.IsBot).ToList(); + var removed = 0; foreach (var bot in bots) { if (await context.DeleteAsync(bot).ConfigureAwait(false)) { deleted++; + removed++; + } + else + { + // Not silent: a bot account which survives the reset is spawned again right after + // it, and the paging below would never look at it a second time. + this._logger.LogWarning("Bot account '{LoginName}' could not be deleted for the bot reset.", bot.LoginName); } } // Commit this page's deletions before paging on, so the ordering used by the next query - // reflects the removals: the non-bot accounts of this page now occupy [skip, skip + nonBotCount). - if (bots.Count > 0) + // reflects the removals: what is left of this page now occupies [skip, skip + kept). + if (removed > 0) { await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); } - skip += page.Count - bots.Count; + skip += page.Count - removed; } return deleted; @@ -405,7 +413,7 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam character.Inventory = context.CreateNew(); character.Inventory.Money = StartMoney; - this.EquipStarterGear(context, character); + this.EquipStarterGear(context, character, resetConfiguration is not null); account.Characters.Add(character); } @@ -454,7 +462,7 @@ private void LearnClassSkills(IPlayerContext context, Character character, Chara /// account gear), so it is not naked and punching with its fists. The item level scales modestly /// with the bot level for a bit more defense/damage without raising the equip requirements too high. /// - private void EquipStarterGear(IPlayerContext context, Character character) + private void EquipStarterGear(IPlayerContext context, Character character, bool resetMeta) { var inventory = character.Inventory!; var characterClass = character.CharacterClass!; @@ -464,32 +472,17 @@ private void EquipStarterGear(IPlayerContext context, Character character) // - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff), // - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set, // and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots). - // Choose the weapon type by the class's intrinsic stats: archers (agility) get a bow, pure casters - // (energy) a staff, everyone else (warriors and the Magic Gladiator hybrid) a melee blade. The Small - // Axe is qualified for almost every class, so without this casters and archers would all end up with one. - float ClassStat(AttributeDefinition attribute) - => characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == attribute)?.BaseValue ?? 0f; - var strength = ClassStat(Stats.BaseStrength); - var agility = ClassStat(Stats.BaseAgility); - var energy = ClassStat(Stats.BaseEnergy); - Func isPreferredWeapon; - if (agility > strength && agility > energy) - { - isPreferredWeapon = d => d.Group == BowGroup; - } - else if (energy > strength) - { - isPreferredWeapon = d => d.Group == StaffGroup; - } - else - { - isPreferredWeapon = d => d.Group <= MaxMeleeGroup; - } + // The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the + // later upgrades use), so an energy-specced Magic Gladiator starts with a staff instead of a blade. + // The Small Axe is qualified for almost every class, so without this filter casters and archers would + // all end up with one. + bool IsPreferredWeapon(ItemDefinition definition) + => BotProgression.IsPreferredWeaponGroup(characterClass, character.Name, resetMeta, (byte)definition.Group); // Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every // archer would get a bolt stack as its "weapon" and end up punching with its fists. var weapon = this._gameContext.Configuration.Items - .Where(d => isPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass)) + .Where(d => IsPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass)) .MinBy(d => d.DropLevel) ?? this._gameContext.Configuration.Items .Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass)) diff --git a/src/GameLogic/Bots/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs index 05455fca0..bbca39986 100644 --- a/src/GameLogic/Bots/BotJewelHandler.cs +++ b/src/GameLogic/Bots/BotJewelHandler.cs @@ -172,8 +172,11 @@ private static async ValueTask ApplyJewelAsync(OfflinePlayer player, Item { var inventory = player.Inventory!; var equipSlot = target.ItemSlot; - var freeSlot = inventory.FreeSlots.FirstOrDefault(s => s > InventoryConstants.LastEquippableItemSlotIndex, (byte)0); - if (freeSlot == 0) + + // A free slot is not enough - the piece needs a hole of its SIZE (a 2x3 armor does not fit into + // the 1x1 gap a jewel left behind), which is exactly what CheckInvSpace answers. + if (inventory.CheckInvSpace(target) is not { } freeSlot + || freeSlot <= InventoryConstants.LastEquippableItemSlotIndex) { return false; } diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index d0da5feba..4b48a3c21 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -108,14 +108,7 @@ public async ValueTask StopAllAsync() { if (this._bots.TryRemove(key, out var bot)) { - try - { - await bot.StopAsync().ConfigureAwait(false); - } - catch (Exception ex) - { - bot.Logger.LogError(ex, "Error while stopping bot '{Key}'.", key); - } + await StopAndDisposeAsync(bot, key, "shutdown").ConfigureAwait(false); } } } @@ -148,14 +141,15 @@ public async ValueTask StopAllAsync() { await party.KickMySelfAsync(bot).ConfigureAwait(false); } - - await bot.StopAsync().ConfigureAwait(false); } catch (Exception ex) { - bot.Logger.LogError(ex, "Error while stopping bot '{Key}' for presence rotation.", key); + // A failed party goodbye must not skip the stop below - the party cleans up a + // disconnected member itself. + bot.Logger.LogWarning(ex, "Bot '{Key}' couldn't leave its party for the presence rotation.", key); } + await StopAndDisposeAsync(bot, key, "presence rotation").ConfigureAwait(false); return name; } @@ -209,6 +203,10 @@ public async ValueTask RestartBotAsync(IGameContext gameContext, BotPlayer return false; } + // The stopped instance is done for good - release its persistence context and the tracked + // account graph before the fresh one loads them again. + await removed.DisposeAsync().ConfigureAwait(false); + return await this.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false); } @@ -279,6 +277,29 @@ public async ValueTask FormPartiesAsync(IGameContext gameContext) private static string GetKey(string loginName, byte slot) => $"{loginName}/{slot}"; + /// + /// Stops the bot like a regular logout (which saves its progress) and releases its resources. + /// A stopped-but-not-disposed player keeps its persistence context - and with it the whole tracked + /// account graph - alive; with the presence rotation stopping bots around the clock, that adds up + /// to a leak. Mirrors the teardown of the ; the removal + /// from the game context happens through the disconnect event. + /// + private static async ValueTask StopAndDisposeAsync(BotPlayer bot, string key, string reason) + { + try + { + await bot.StopAsync().ConfigureAwait(false); + } + catch (Exception ex) + { + bot.Logger.LogError(ex, "Error while stopping bot '{Key}' ({Reason}).", key, reason); + } + finally + { + await bot.DisposeAsync().ConfigureAwait(false); + } + } + private async ValueTask RemoveAndDisposeAsync(string key, BotPlayer bot) { this._bots.TryRemove(key, out _); diff --git a/src/GameLogic/Bots/BotMiniGameHandler.cs b/src/GameLogic/Bots/BotMiniGameHandler.cs index 829ed8ae3..f811562e8 100644 --- a/src/GameLogic/Bots/BotMiniGameHandler.cs +++ b/src/GameLogic/Bots/BotMiniGameHandler.cs @@ -76,15 +76,24 @@ internal static void BringPartyBotsAlong(Player leader, IReadOnlyList definition.MaximumCharacterLevel) + if (level > maximumLevel) { - reason = $"level {level} is above the maximum of {definition.MaximumCharacterLevel}"; + reason = $"level {level} is above the maximum of {maximumLevel}"; return false; } diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index afb92efd4..8157c8e9f 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -62,8 +62,14 @@ internal sealed class BotNavigator : AsyncDisposable /// Item number of the Large Mana Potion within . private const int ManaPotionNumber = 6; - /// Stack size (the potion item's Durability holds the remaining count; one is spent per heal). - private const byte HealPotionStack = 255; + /// + /// How many charges an emergency refill stocks up (the potion item's Durability holds the remaining + /// count; one is spent per heal). Spread over as many item stacks as the definition's stack size + /// needs: a Large Healing Potion holds three charges per stack, so a bot handed a single 255-charge + /// "potion" carried an item the game cannot produce - its stack never looked low again (the shopping + /// loop for that potion kind went dead), and stacking it with a bought one underflowed the count. + /// + private const int EmergencyPotionCharges = 21; /// /// Emergency refill threshold: normally the bot restocks potions by BUYING them at a merchant (see @@ -131,6 +137,13 @@ internal sealed class BotNavigator : AsyncDisposable /// Cooldown for following the party leader to another map (shorter, so the group regroups quickly). private static readonly TimeSpan FollowWarpCooldown = TimeSpan.FromSeconds(20); + /// + /// How long the party leader has to stay on a map before its bots follow him there. A leader who is + /// only passing through (a town trip for supplies, a gate on the way somewhere) must not drag his + /// whole party across the world and back - real party members wait to see where he settles, too. + /// + private static readonly TimeSpan LeaderSettleTime = TimeSpan.FromSeconds(20); + /// /// Bounds of the random delay between becoming eligible for a character reset and performing it. /// A real player finishes the fight, walks to town, maybe trades first - and 250 bots resetting @@ -187,6 +200,8 @@ internal sealed class BotNavigator : AsyncDisposable private Point? _shoppingTarget; private DateTime _nextShoppingCheckUtc = DateTime.MinValue; private DateTime? _resetDueAtUtc; + private short _leaderMapNumber; + private DateTime _leaderOnMapSinceUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -203,8 +218,13 @@ public BotNavigator(OfflinePlayer player) /// public void Start() { + // The token is captured ONCE, not read from the (later disposed) source on every shot: a timer + // callback which is already running when Dispose gets rid of the source would throw an + // ObjectDisposedException right in the callback - unhandled, on a thread pool thread, taking the + // whole server process down with it. Same pattern as the MU Helper tick. + var cancellationToken = this._cts.Token; this._timer ??= new Timer( - state => _ = this.SafeEvaluateAsync(this._cts.Token), + state => _ = this.SafeEvaluateAsync(cancellationToken), null, TimeSpan.FromMilliseconds(2000 + Rand.NextInt(0, 1500)), EvaluationInterval); @@ -548,14 +568,12 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) return true; } - if (await BotShoppingHandler.TryTradeAsync(this._player, map, target).ConfigureAwait(false)) - { - // Still standing safely at the merchant with the dialog closed - the player-like moment - // to spend a few looted jewels on the own gear (see BotJewelHandler). Queued into the - // MuHelper tick: applying a jewel takes gear off and back on, and such attribute - // mutations must not race the combat handler (see PendingBotActions). - this._player.PendingBotActions.Enqueue(() => BotJewelHandler.TryUpgradeGearAsync(this._player)); - } + // The trade itself runs in the MU Helper tick, not here: selling and buying mutate the + // inventory and the Zen of a character whose combat tick may be running at this very moment + // (the open NPC dialog only holds off the NEXT tick) - and both write through the same, + // not-thread-safe persistence context. Same rule as for every other bot-initiated mutation, + // see PendingBotActions. The jewels are spent right after the trade, in the same tick. + this._player.PendingBotActions.Enqueue(() => this.TradeAndUpgradeAsync(map, target)); this._shoppingTarget = null; this._player.IsOnShoppingTrip = false; @@ -611,6 +629,19 @@ private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) return true; } + /// + /// The trade at the merchant, as it runs inside the MU Helper tick: sell the junk, restock the + /// potions and - still standing safely at the shop with the dialog closed - spend a few of the + /// looted jewels on the own gear, the player-like moment for it (see ). + /// + private async ValueTask TradeAndUpgradeAsync(GameMap map, Point merchantPosition) + { + if (await BotShoppingHandler.TryTradeAsync(this._player, map, merchantPosition).ConfigureAwait(false)) + { + await BotJewelHandler.TryUpgradeGearAsync(this._player).ConfigureAwait(false); + } + } + /// /// Marches the bot back to the place of its death while a revenge is armed (see /// ). The single attempt is spent as soon as @@ -800,6 +831,15 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) return true; } + // Only follow a leader who SETTLED on the new map: a leader in transit (pulling a town + // scroll for shopping, passing through a gate, hopping back and forth) used to drag its + // whole party along on every hop - and since the followers land on the map's spawn gate + // rather than next to him, they were still walking over when he warped away again. + if (!this.HasLeaderSettled(leader)) + { + return true; + } + // Prefer a spawn gate of the leader's map; maps without one (the Dungeon has no town) // fall back to the warp list gate - the same spot the leader warped to. Without the // fallback a follower could neither warp after its leader nor hunt (following consumed @@ -834,6 +874,26 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) return false; } + /// + /// Whether the leader has been on his current map long enough () for the + /// bot to follow him there. Tracks the leader's map per bot, so each follower makes the call on its own. + /// + private bool HasLeaderSettled(Player leader) + { + if (leader.CurrentMap?.Definition is not { } leaderMap) + { + return false; + } + + if (this._leaderMapNumber != leaderMap.Number) + { + this._leaderMapNumber = leaderMap.Number; + this._leaderOnMapSinceUtc = DateTime.UtcNow; + } + + return DateTime.UtcNow - this._leaderOnMapSinceUtc >= LeaderSettleTime; + } + /// /// Performs the bot's character reset once it is due (reset feature enabled, required level /// reached, reset limit not exhausted). The reset is not executed the moment the bot becomes @@ -1087,9 +1147,9 @@ private async ValueTask EnsureAmmunitionAsync() if (inventory.EquippedAmmunitionItem is { } ammo) { - if (ammo.Durability < HealPotionRefillBelow) + if (ammo.Durability < HealPotionRefillBelow && ammo.Definition is { } ammoDefinition) { - ammo.Durability = HealPotionStack; + ammo.Durability = Math.Max((byte)1, ammoDefinition.Durability); } return; @@ -1113,7 +1173,7 @@ private async ValueTask EnsureAmmunitionAsync() var item = this._player.PersistenceContext.CreateNew(); item.Definition = arrows; - item.Durability = HealPotionStack; + item.Durability = Math.Max((byte)1, arrows.Durability); var targetSlot = bow.ItemSlot == InventoryConstants.RightHandSlot ? InventoryConstants.LeftHandSlot : InventoryConstants.RightHandSlot; @@ -1125,37 +1185,47 @@ private async ValueTask EnsureAmmunitionAsync() private async ValueTask EnsurePotionStackAsync(int potionNumber) { - if (this._player.Inventory is not { } inventory) + if (this._player.Inventory is not { } inventory + || this._player.GameContext.Configuration.Items + .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == potionNumber) is not { } definition) { return; } - var potion = inventory.Items.FirstOrDefault(i => - i.Definition?.Group == HealPotionGroup && i.Definition.Number == potionNumber); - if (potion is not null) + var potions = inventory.Items + .Where(i => i.Definition?.Group == HealPotionGroup && i.Definition.Number == potionNumber) + .ToList(); + var charges = potions.Sum(p => (int)p.Durability); + if (charges >= HealPotionRefillBelow) { - if (potion.Durability < HealPotionRefillBelow) - { - potion.Durability = HealPotionStack; - } - return; } - var definition = this._player.GameContext.Configuration.Items - .FirstOrDefault(d => d.Group == HealPotionGroup && d.Number == potionNumber); - if (definition is null) + // A stack holds as many charges as the item definition allows - no more (see EmergencyPotionCharges). + var stackSize = Math.Max((byte)1, definition.Durability); + foreach (var potion in potions.Where(p => p.Durability < stackSize)) { - return; + charges += (int)(stackSize - potion.Durability); + potion.Durability = stackSize; + if (charges >= EmergencyPotionCharges) + { + return; + } } - var item = this._player.PersistenceContext.CreateNew(); - item.Definition = definition; - item.Durability = HealPotionStack; - if (!await inventory.AddItemAsync(item).ConfigureAwait(false)) + while (charges < EmergencyPotionCharges) { - // No free slot (should not happen for a bot) - drop the throw-away item again. - await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + var item = this._player.PersistenceContext.CreateNew(); + item.Definition = definition; + item.Durability = stackSize; + if (!await inventory.AddItemAsync(item).ConfigureAwait(false)) + { + // Backpack full - drop the throw-away item again; what is stocked has to do. + await this._player.PersistenceContext.DeleteAsync(item).ConfigureAwait(false); + return; + } + + charges += stackSize; } } @@ -1316,7 +1386,7 @@ private int BestSafeLevel(GameMapDefinition mapDefinition) var best = 0; foreach (var area in mapDefinition.MonsterSpawns) { - if (area is not { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + if (!this.IsPermanentMonsterSpawn(area)) { continue; } @@ -1331,6 +1401,19 @@ private int BestSafeLevel(GameMapDefinition mapDefinition) return best; } + /// + /// Whether the spawn area holds monsters which actually stand on the map at all times. The event + /// spawns (golden monsters, the invasion and wave areas) are configured on the regular maps too, but + /// only exist while their event runs - a bot judging a map by them would travel to hunting grounds + /// which are empty most of the day, and would think its current map holds far stronger monsters than + /// it can find there. Same class of mistake as walking to a merchant that is only spawned now and + /// then, see . + /// + private bool IsPermanentMonsterSpawn(MonsterSpawnArea area) + { + return area is { Quantity: > 0, SpawnTrigger: SpawnTrigger.Automatic, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }; + } + /// /// Picks the legally warpable map (other than the current one) that offers the strongest monsters the /// bot can still safely handle, if it is meaningfully better than the current map, with its warp gate. @@ -1383,7 +1466,7 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL groundLevel = 0; var candidates = map.Definition.MonsterSpawns - .Where(a => a is { Quantity: > 0, MonsterDefinition.ObjectKind: NpcObjectKind.Monster }) + .Where(this.IsPermanentMonsterSpawn) .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!))) .Where(x => x.Level > 0) .ToList(); diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs index 1b0ae817d..ae96e4227 100644 --- a/src/GameLogic/Bots/BotPlayer.cs +++ b/src/GameLogic/Bots/BotPlayer.cs @@ -27,6 +27,15 @@ public BotPlayer(IGameContext gameContext) /// public override bool RespawnAndContinue => true; + /// + /// Gets or sets a value indicating whether this bot evolved into its master class and still needs + /// the "relog" which mounts the master attributes (see ). + /// Set from the bot's own tick, where the evolution runs; acted upon by the maintenance pass, which + /// is the only place allowed to restart a bot (a restart from within the bot's own timer callback + /// would tear down the very loop it runs in). + /// + public bool AwaitsMasterRestart { get; set; } + /// public override async ValueTask StopAsync() { diff --git a/src/GameLogic/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs index e4948abbd..49176f34f 100644 --- a/src/GameLogic/Bots/BotProgression.cs +++ b/src/GameLogic/Bots/BotProgression.cs @@ -376,14 +376,21 @@ public static bool MeetsRequirements(Skill skill, Func - /// Determines whether a weapon of the given item group fits the class's fighting style: archers - /// (agility-based classes) use bows, pure casters staves, everyone else melee weapons. Mirrors the - /// starter-weapon choice of the , so an elf never swaps its bow for a - /// random axe it happens to be qualified for (which would also displace its arrows). + /// Determines whether a weapon of the given item group fits the fighting style of this bot's BUILD: + /// archers use bows, casters staves, everyone else melee weapons. The build decides, not just the + /// class - a Magic Gladiator specced into energy (see the variants in ) + /// is a caster and must get a staff, while its strength-specced sibling wants a blade; deciding by + /// the class's base attributes alone handed both of them swords. Classes whose base attributes make + /// them archers (the elves) keep their bow in every build - it is the only weapon they can wield. + /// Used both for the starter gear () and for later upgrades + /// (), so an elf never swaps its bow for a random axe it happens to + /// be qualified for (which would also displace its arrows). /// /// The character class. + /// The character name; decides the build variant, see . + /// Whether the reset-server meta profile applies. /// The item group of the weapon. - public static bool IsPreferredWeaponGroup(CharacterClass characterClass, byte itemGroup) + public static bool IsPreferredWeaponGroup(CharacterClass characterClass, string characterName, bool resetMeta, byte itemGroup) { const byte maxMeleeGroup = 3; const byte bowGroup = 4; @@ -401,7 +408,10 @@ float ClassStat(AttributeDefinition attribute) return itemGroup == bowGroup; } - if (energy > strength) + // The build's primary stat (the first weight, see GetStatWeights) tells a caster from a fighter; + // the class fallback covers classes without an energy build of their own. + var primaryStat = GetStatWeights(characterClass, characterName, resetMeta)[0].Stat; + if (primaryStat == Stats.BaseEnergy || energy > strength) { return itemGroup == staffGroup; } diff --git a/src/GameLogic/ItemExtensions.cs b/src/GameLogic/ItemExtensions.cs index f3658f466..d9f24aa36 100644 --- a/src/GameLogic/ItemExtensions.cs +++ b/src/GameLogic/ItemExtensions.cs @@ -137,6 +137,37 @@ public static bool IsShield(this Item item) return item.Definition?.Group == ShieldItemGroup; } + /// + /// Determines whether equipping an item of this definition into the given hand slot would conflict + /// with what the other hand already holds: a two-handed item needs the other hand free (ammunition + /// aside), and nothing but ammunition fits next to an already equipped two-handed item. + /// + /// The definition of the item which would be equipped. + /// The inventory to check the other hand in. + /// The hand slot the item would be equipped into. + /// true if the other hand blocks this item; otherwise, false. + public static bool ConflictsWithEquippedHands(this ItemDefinition itemDefinition, IStorage inventory, byte toSlot) + { + if (itemDefinition.ItemSlot is null) + { + return false; + } + + static bool IsOneHandedOrShield(ItemDefinition definition) => + (definition.ItemSlot!.ItemSlots.Contains(InventoryConstants.RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(InventoryConstants.LeftHandSlot)) + || definition.Group == ShieldItemGroup; + + var rightHandItemDefinition = inventory.GetItem(InventoryConstants.RightHandSlot)?.Definition; + + return (toSlot == InventoryConstants.LeftHandSlot + && itemDefinition.Width >= 2 + && rightHandItemDefinition is not null + && !rightHandItemDefinition.IsAmmunition) + || (toSlot == InventoryConstants.RightHandSlot + && IsOneHandedOrShield(itemDefinition) + && inventory.GetItem(InventoryConstants.LeftHandSlot)?.Definition?.Width >= 2); + } + /// /// Determines whether this item is a jewelry (pendant or ring) item. /// diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index afb0a7842..bfe7cc60c 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -67,7 +67,7 @@ public sealed class CombatHandler /// 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. /// - private static readonly ConcurrentDictionary MonsterStatsCache = new(); + private static readonly ConcurrentDictionary MonsterStatsCache = new(); private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; @@ -118,6 +118,14 @@ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHa /// private Point OriginPosition => this._player.HuntingOrigin; + /// + /// 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. + /// + private bool IsBot => this._player.Account?.IsBot == true; + /// /// Calculates the hunting range in tiles from the specified configuration. /// @@ -154,7 +162,7 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config) /// The monster definition. public static bool IsSafeTarget(Player player, MonsterDefinition monster) { - var (monsterLevel, averageDamage, monsterDefense, monsterHealth) = GetMonsterCombatStats(monster); + var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster); if (monsterLevel <= 0 || player.Attributes is not { } attributes) { return false; @@ -168,7 +176,7 @@ public static bool IsSafeTarget(Player player, MonsterDefinition monster) return false; } - var netHit = averageDamage - attributes[Stats.DefensePvm]; + var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate); if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth])) { return false; @@ -209,16 +217,20 @@ public async ValueTask PerformAttackAsync() // 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. - if (!ReferenceEquals(previousTarget, this._currentTarget)) + // 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) { - this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900)); - } + 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; + if (DateTime.UtcNow < this._engageAtUtc) + { + this._player.Rotation = this._player.GetDirectionTo(this._currentTarget); + return; + } } byte attackRange = this.GetEffectiveAttackRange(); @@ -286,7 +298,7 @@ public async ValueTask PerformDrainLifeRecoveryAsync() } } - private static (int Level, float AverageDamage, float Defense, float Health) GetMonsterCombatStats(MonsterDefinition monster) + private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster) { return MonsterStatsCache.GetOrAdd(monster, static m => { @@ -295,10 +307,31 @@ float GetValue(AttributeDefinition attribute) var level = (int)GetValue(Stats.Level); var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f; - return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth)); + return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm)); }); } + /// + /// 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 + /// ), 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). + /// + 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); + } + /// /// 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 @@ -312,7 +345,12 @@ private static float GetAttackPower(Player player) } var physical = (attributes[Stats.MinimumPhysBaseDmg] + attributes[Stats.MaximumPhysBaseDmg]) / 2f; - var wizardry = attributes[Stats.WizardryBaseDmg]; + + // 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 ?? []) @@ -394,15 +432,17 @@ private void RefreshTarget() if (this._currentTarget is null) { var targets = this.GetAttackableTargetsInHuntingRange().ToList(); - - // Choose 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. var candidates = targets .Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc) .OrderBy(m => m.GetDistanceTo(this._player)) - .Take(2) + .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; } diff --git a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs index 488c775fa..d0bdef6a8 100644 --- a/src/GameLogic/PlayerActions/Items/MoveItemAction.cs +++ b/src/GameLogic/PlayerActions/Items/MoveItemAction.cs @@ -263,18 +263,7 @@ private async ValueTask CanMoveAsync(Player player, Item item, byte to if (itemDefinition.ItemSlot.ItemSlots.Contains(toSlot) && player.CompliesRequirements(item)) { - static bool IsOneHandedOrShield(ItemDefinition definition) => - (definition.ItemSlot!.ItemSlots.Contains(RightHandSlot) && definition.ItemSlot.ItemSlots.Contains(LeftHandSlot)) || definition.Group == 6; - - var rightHandItemDefinition = storage.GetItem(RightHandSlot)?.Definition!; - - if ((toSlot == LeftHandSlot - && itemDefinition.Width >= 2 - && rightHandItemDefinition != null - && !rightHandItemDefinition.IsAmmunition) - || (toSlot == RightHandSlot - && IsOneHandedOrShield(itemDefinition) - && storage.GetItem(LeftHandSlot)?.Definition!.Width >= 2)) + if (itemDefinition.ConflictsWithEquippedHands(storage, toSlot)) { // Attempting to equip a two-handed item to the left hand slot when a shield is in the right hand slot, // or trying to equip a one-handed weapon or shield to the right hand slot when a two-handed item is in the left hand slot. diff --git a/tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs new file mode 100644 index 000000000..ed24a8f12 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotEquipmentHandlerTest.cs @@ -0,0 +1,177 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using Moq; +using MUnique.OpenMU.DataModel; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests which gear a bot considers an upgrade (, also +/// the pickup filter of the offline ) +/// and the hand rule it shares with the engine (). +/// +[TestFixture] +public class BotEquipmentHandlerTest +{ + private const byte StaffGroup = 5; + private const byte ShieldGroup = 6; + private const byte ArmorGroup = 8; + + /// + /// A better weapon of the bot's own fighting style is an upgrade worth picking up. The test player's + /// class is energy-based, so its style is the staff (see ). + /// + [Test] + public async ValueTask BetterWeaponIsAnUpgradeAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false); + var better = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 40)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, better), Is.True); + } + + /// + /// A two-handed weapon needs the other hand: while a shield is worn, it is only worth it if it beats + /// the weapon AND the shield it displaces. Without counting the shield, the bot took its gear off, + /// had the equip refused by the engine (a two-hander needs the hand free), put the old weapon back on + /// and started over - hundreds of swaps an hour, unarmed half of the time. + /// + [Test] + public async ValueTask TwoHandedWeaponIsNoUpgradeWhenItLosesToWeaponAndShieldAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false); + + var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.False); + } + + /// + /// A two-handed weapon which beats the worn weapon and shield together is worth the swap - the bot + /// frees the hand for it, like a player would. + /// + [Test] + public async ValueTask TwoHandedWeaponIsAnUpgradeWhenItBeatsWeaponAndShieldAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, ShieldGroup, 1, dropLevel: 40, slot: InventoryConstants.RightHandSlot), InventoryConstants.RightHandSlot).ConfigureAwait(false); + + var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 120, width: 2)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True); + } + + /// + /// Without a shield in the way, the same two-handed weapon is a welcome upgrade. + /// + [Test] + public async ValueTask TwoHandedWeaponIsAnUpgradeWithFreeOffHandAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 10), InventoryConstants.LeftHandSlot).ConfigureAwait(false); + + var twoHanded = CreateItem(CreateDefinition(player, StaffGroup, 3, dropLevel: 60, width: 2)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, twoHanded), Is.True); + } + + /// + /// A weapon never goes into the free off-hand: a bot dual-wielding the junk weapons it happens to be + /// qualified for is neither useful nor a sight a real character offers. + /// + [Test] + public async ValueTask JunkWeaponIsNoUpgradeForTheFreeOffHandAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + await WearAsync(player, CreateDefinition(player, StaffGroup, 1, dropLevel: 50), InventoryConstants.LeftHandSlot).ConfigureAwait(false); + + var junk = CreateItem(CreateDefinition(player, StaffGroup, 2, dropLevel: 5)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, junk), Is.False); + } + + /// + /// Gear the bot's class cannot wear is no upgrade, however good it is. + /// + [Test] + public async ValueTask UnqualifiedGearIsNoUpgradeAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var definition = CreateDefinition(player, ArmorGroup, 1, dropLevel: 80, slot: InventoryConstants.ArmorSlot); + definition.QualifiedCharacters.Clear(); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, CreateItem(definition)), Is.False); + } + + /// + /// An empty armor slot takes any qualified piece - that is what makes a naked bot dress itself. + /// + [Test] + public async ValueTask ArmorForAnEmptySlotIsAnUpgradeAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var armor = CreateItem(CreateDefinition(player, ArmorGroup, 1, dropLevel: 20, slot: InventoryConstants.ArmorSlot)); + + Assert.That(BotEquipmentHandler.IsUpgradeFor(player, armor), Is.True); + } + + private static ItemDefinition CreateDefinition(Player player, byte group, short number, byte dropLevel, byte width = 1, int? slot = null) + { + var definitionMock = new Mock(); + definitionMock.SetupAllProperties(); + definitionMock.Setup(d => d.QualifiedCharacters).Returns(new List()); + definitionMock.Setup(d => d.PossibleItemOptions).Returns(new List()); + definitionMock.Setup(d => d.BasePowerUpAttributes).Returns(new List()); + definitionMock.Setup(d => d.Requirements).Returns(new List()); + + var slotTypeMock = new Mock(); + var targetSlot = slot ?? InventoryConstants.LeftHandSlot; + var slots = targetSlot == InventoryConstants.LeftHandSlot && group <= ShieldGroup && width < 2 + ? new List { InventoryConstants.LeftHandSlot, InventoryConstants.RightHandSlot } + : new List { targetSlot }; + slotTypeMock.Setup(s => s.ItemSlots).Returns(slots); + definitionMock.Setup(d => d.ItemSlot).Returns(slotTypeMock.Object); + + var definition = definitionMock.Object; + definition.Group = group; + definition.Number = number; + definition.Width = width; + definition.Height = 2; + definition.Durability = 100; + definition.DropLevel = dropLevel; + definition.QualifiedCharacters.Add(player.SelectedCharacter!.CharacterClass!); + player.GameContext.Configuration.Items.Add(definition); + return definition; + } + + private static Item CreateItem(ItemDefinition definition) + { + var itemMock = new Mock(); + itemMock.SetupAllProperties(); + itemMock.Setup(i => i.ItemOptions).Returns(new List()); + itemMock.Setup(i => i.ItemSetGroups).Returns(new List()); + var item = itemMock.Object; + item.Definition = definition; + item.Durability = definition.Durability; + return item; + } + + private static async ValueTask WearAsync(Player player, ItemDefinition definition, byte slot) + { + var item = CreateItem(definition); + await player.Inventory!.AddItemAsync(slot, item).ConfigureAwait(false); + return item; + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs index 2ef5ac513..46be4c0f1 100644 --- a/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs @@ -41,6 +41,38 @@ public async ValueTask EnforcesLevelBracketAsync(int level, bool expected) Assert.That(reason, expected ? Is.Empty : Is.Not.Empty); } + /// + /// The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter in their own + /// level bracket, exactly like they do for a player: a qualified Magic Gladiator must not be judged + /// - and kicked out of its leader's party - by the bracket of the regular classes. + /// + /// The bot's character level. + /// Whether the bot qualifies. + [TestCase(200, false)] + [TestCase(221, true)] + [TestCase(280, true)] + [TestCase(281, false)] + public async ValueTask EnforcesSpecialCharacterLevelBracketAsync(int level, bool expected) + { + var gameContext = GameContextTestHelper.CreateGameContext(); + var bot = await CreateBotAsync(gameContext).ConfigureAwait(false); + bot.Attributes![Stats.Level] = level; + + // That is what makes a character "special" for the entry rules (see CharacterExtensions). + bot.SelectedCharacter!.CharacterClass!.LevelWarpRequirementReductionPercent = 50; + var definition = new MiniGameDefinition + { + MinimumCharacterLevel = 281, + MaximumCharacterLevel = 330, + MinimumSpecialCharacterLevel = 221, + MaximumSpecialCharacterLevel = 280, + }; + + var eligible = BotMiniGameHandler.IsEligible(bot, definition, out _); + + Assert.That(eligible, Is.EqualTo(expected)); + } + /// /// An event for master classes only is not entered before the bot's master evolution. /// From a77fed1184ecc2f5f1d33a934c4b088b7738375a Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 10:55:59 +0200 Subject: [PATCH 41/60] Let a player recruit a bot which hunts in a bot party A player who invited a bot got 'the player is already in a party' more often than not: the maintenance groups about 60 percent of the population into bot parties, and a bot in a party turned every invitation down. A living player takes precedence over the bot's own company, so the bot now leaves its bot party and joins the inviter. When it LEADS that party, the group is broken up instead of handing it over: the engine does not pass the mastership on when the master leaves, which would leave the remaining bots following a leader who is not in their party anymore. Their next hourly re-formation groups them again. The party request action only offered the auto-accept to a player who is the master of their party, so a bot which was a plain member never got asked at all. It is offered to server-side bots as well now - gated by Account.IsBot, so players and the human offline mode keep the old rule. A bot which already hunts with a human keeps refusing, like before: there the boredom timer decides when it leaves. --- src/GameLogic/Bots/BotPartyHandler.cs | 44 +++++++++++++++++-- .../PlayerActions/Party/PartyRequestAction.cs | 7 ++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/src/GameLogic/Bots/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs index 815b9d257..c93e17bab 100644 --- a/src/GameLogic/Bots/BotPartyHandler.cs +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -55,7 +55,7 @@ internal static async ValueTask TryScheduleAcceptAsync(Player receiver, Pl { if (receiver is not OfflinePlayer bot || bot.Account?.IsBot != true - || bot.Party is not null + || HasHumanCompanion(bot) || bot.PendingPartyInvite is not null) { return false; @@ -139,14 +139,21 @@ internal static bool HasHumanCompanion(Player bot) private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player requester) { - // Re-validate: between the invitation and this answer, the bot may have grouped up (the hourly - // bot party formation) and the inviter may have died, left the game or joined another party. - if (bot.Party is not null || !IsRequesterEligible(bot, requester)) + // Re-validate: between the invitation and this answer, the bot may have joined a human's party + // and the inviter may have died, left the game or joined another party. + if (HasHumanCompanion(bot) || !IsRequesterEligible(bot, requester)) { bot.Logger.LogInformation("Bot '{Name}' dropped the party invitation of '{Requester}' - the situation changed.", bot.Name, requester.Name); return; } + await LeaveBotPartyAsync(bot).ConfigureAwait(false); + if (bot.Party is not null) + { + bot.Logger.LogInformation("Bot '{Name}' could not leave its bot party for '{Requester}'.", bot.Name, requester.Name); + return; + } + bool success; if (requester.Party is { } requesterParty) { @@ -173,6 +180,35 @@ private static async ValueTask AcceptInvitationAsync(OfflinePlayer bot, Player r } } + /// + /// Lets the bot leave the bot-only party it hunts in, so it can join the player who invited it: a + /// living player takes precedence over the bot's own company. When the bot LEADS that party, the + /// group is broken up instead - the engine does not hand the mastership over to another member when + /// the master leaves (it only removes them from the member list), which would leave the remaining + /// bots following a leader who is not in their party anymore. Their next hourly re-formation groups + /// them again (see ). + /// + private static async ValueTask LeaveBotPartyAsync(OfflinePlayer bot) + { + if (bot.Party is not { } party) + { + return; + } + + if (Equals(party.PartyMaster, bot)) + { + bot.Logger.LogInformation("Bot '{Name}' breaks up its bot party to join a player.", bot.Name); + foreach (var member in party.PartyList.ToList()) + { + await party.KickMySelfAsync(member).ConfigureAwait(false); + } + + return; + } + + await party.KickMySelfAsync(bot).ConfigureAwait(false); + } + private static bool IsRequesterEligible(OfflinePlayer bot, Player requester) { if (!requester.IsAlive || requester.PlayerState.CurrentState != PlayerState.EnteredWorld) diff --git a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs index c10bf92ef..595cc4797 100644 --- a/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs +++ b/src/GameLogic/PlayerActions/Party/PartyRequestAction.cs @@ -28,7 +28,12 @@ public async ValueTask HandlePartyRequestAsync(Player player, Player toRequest) if (toRequest.Party != null || toRequest.LastPartyRequester != null) { - if (toRequest.Party != null && Equals(toRequest.Party.PartyMaster, toRequest)) + // A server-side bot is asked as well when it is a plain member of its (bot) party: a living + // player takes precedence over the bot's own company, so it leaves that party and joins the + // inviter (see BotPartyHandler). Everyone else keeps the original rule - only the master of a + // party can answer an invitation. + var isBot = toRequest.Account?.IsBot == true; + if (toRequest.Party != null && (isBot || Equals(toRequest.Party.PartyMaster, toRequest))) { if (await PartyRequestHandler.TryAutoAcceptPartyRequestAsync(toRequest, player).ConfigureAwait(false)) { From f97c7eaba4c3db70de91790cc9ba38b2af2e8ed9 Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 11:22:37 +0200 Subject: [PATCH 42/60] Split the bot population over the game servers Bots count towards the player count of their game server exactly like players do, and a server which reached its maximum player count turns new clients away: a population large enough to fill a server locked the real players out of it. On top of that, the whole population ended up on a single server anyway - the feature plugin is one instance shared by all game servers of the process, while its periodic task is called by each of them, so the server whose timer fired first animated everything and the others animated nothing. The state of the feature is per game server now (its own bots, its own startup and maintenance schedule), and which accounts a server animates is a pure function of the account index and the set of configured game servers: every server computes the same split without asking the others, which also holds when each game server is its own process. The share of a server is proportional to its capacity, and only a part of that capacity (the new 'Bot capacity %' setting, 60 by default) is offered to the bots - the rest stays reserved for the players, who must never be denied a slot by a bot. Accounts which do not fit stay offline until the deployment offers the room for them. Exactly one server generates the population, so the accounts and their unique character names are never created twice at the same time; the other servers retry the spawns of their own share until the accounts exist. The split is computed when a server starts, so adding a game server to a running deployment takes a restart before the bots spread onto it. That is deliberate: moving a bot between two running servers would mean animating one account from two contexts - the very situation which corrupts a character. --- src/GameLogic/Bots/BotConfiguration.cs | 20 ++ src/GameLogic/Bots/BotFeaturePlugIn.cs | 255 +++++++++++------- src/GameLogic/Bots/BotServerPartition.cs | 186 +++++++++++++ .../BotServerPartitionTest.cs | 123 +++++++++ 4 files changed, 493 insertions(+), 91 deletions(-) create mode 100644 src/GameLogic/Bots/BotServerPartition.cs create mode 100644 tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs index efc50097c..68efd2834 100644 --- a/src/GameLogic/Bots/BotConfiguration.cs +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -64,6 +64,18 @@ public class BotConfiguration [Range(1, MaxCharactersPerAccountLimit)] public int MaxCharactersPerAccount { get; set; } = MaxCharactersPerAccountLimit; + /// + /// Gets or sets the share (in percent) of a game server's maximum player count which its bots may + /// occupy. Bots count towards that limit like players do, and a full server turns new clients away - + /// so the rest of the capacity stays reserved for real players, who must never be denied a slot by a + /// bot. The population is split over all configured game servers accordingly (see + /// ); accounts which do not fit stay offline until the servers offer + /// the room for them. + /// + [Display(Name = "Bot capacity %", Description = "Share of a game server's maximum player count which its bots may occupy; the rest stays reserved for real players.")] + [Range(1, 100)] + public int BotCapacityPercent { get; set; } = 60; + /// /// Gets or sets a value indicating whether bots pay the configured reset costs (zen, reset items) /// when they reset their character on a server with the reset feature enabled. Off by default: @@ -89,6 +101,14 @@ public class BotConfiguration public int GetEffectiveCharactersPerAccount() => Math.Clamp(this.MaxCharactersPerAccount, 1, MaxCharactersPerAccountLimit); + /// + /// Gets the effective, clamped share of a server's player capacity which its bots may occupy. + /// + /// A value between 1 and 100. + /// Deliberately a method, like . + public int GetEffectiveBotCapacityPercent() + => Math.Clamp(this.BotCapacityPercent, 1, 100); + /// /// Parses into the distinct, trimmed login names. /// diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index ffec3bba6..d27094907 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using System.Collections.Concurrent; using System.Linq; using System.Runtime.InteropServices; using System.Threading; @@ -41,53 +42,29 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus 0.50, 0.50, 0.55, 0.60, 0.70, 0.80, 0.90, 1.00, 1.00, 0.95, 0.80, 0.50, ]; - private readonly BotManager _botManager = new(); - /// - /// Bots whose respawn after the master evolution failed (see ), - /// retried on the following maintenance passes - without this, a transient spawn failure would - /// leave the character offline until a server restart when the presence rotation is disabled. + /// The state of the feature, per game server: the plugin instance is shared by all game servers of + /// the process, while is called by each of them separately. One shared + /// state would mean the server whose timer fires first animates the whole population (which is how + /// the bots used to end up on a single server), and the other servers doing nothing at all. /// - private readonly System.Collections.Concurrent.ConcurrentQueue<(string Login, byte Slot)> _pendingRespawns = new(); - - private DateTime _nextRunUtc = DateTime.UtcNow + StartupDelay; - private DateTime _nextMaintenanceUtc = DateTime.UtcNow + StartupDelay + StartupDelay; - private DateTime _nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; - - /// - /// 0 = startup not run yet, 1 = startup in progress, 2 = startup done (maintenance mode). - /// The periodic task timer fires every second WITHOUT awaiting the previous invocation, so - /// during the minutes-long generation/spawn further ticks arrive concurrently - they must - /// neither re-enter the startup nor run the maintenance (e.g. the presence rotation) against - /// a half-spawned population. - /// - private int _startupState; - - /// - /// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in parallel - /// (see ). - /// - private int _maintenanceRunning; + private readonly ConcurrentDictionary _states = new(); /// public BotConfiguration? Configuration { get; set; } - /// - /// Gets the bot manager. - /// - public BotManager BotManager => this._botManager; - /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { - if (this._startupState == 2) + var state = this._states.GetOrAdd(gameContext, _ => new ServerState()); + if (state.StartupState == 2) { - await this.RunMaintenanceAsync(gameContext).ConfigureAwait(false); + await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false); return; } - if (DateTime.UtcNow < this._nextRunUtc - || Interlocked.CompareExchange(ref this._startupState, 1, 0) != 0) + if (DateTime.UtcNow < state.NextRunUtc + || Interlocked.CompareExchange(ref state.StartupState, 1, 0) != 0) { return; } @@ -96,30 +73,31 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) if (!configuration.Enabled) { // Not spawned - re-check on the following ticks, the feature may get enabled later. - Interlocked.Exchange(ref this._startupState, 0); + Interlocked.Exchange(ref state.StartupState, 0); return; } try { - await this.SpawnPopulationAsync(gameContext, configuration).ConfigureAwait(false); + await this.SpawnPopulationAsync(gameContext, state, configuration).ConfigureAwait(false); } finally { // Like before: the startup runs once, even when parts of it failed (the errors are // logged); the maintenance pass takes over from here. - Interlocked.Exchange(ref this._startupState, 2); + Interlocked.Exchange(ref state.StartupState, 2); } } - private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfiguration configuration) + private async ValueTask SpawnPopulationAsync(GameContext gameContext, ServerState state, BotConfiguration configuration) { var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); using var scope = logger.BeginScope(gameContext); var generator = new BotGenerator(gameContext, logger); + var partition = state.Partition = await BotServerPartition.CreateAsync(gameContext, configuration, logger).ConfigureAwait(false); - if (configuration.ResetBots) + if (configuration.ResetBots && partition.IsGenerator) { try { @@ -136,18 +114,23 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu } } - try + if (partition.IsGenerator) { - // Generate the persistent bot population if it is not there yet (idempotent). - var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false); - if (created > 0) + try { - logger.LogInformation("Generated {Created} new bot account(s).", created); + // Generate the persistent bot population if it is not there yet (idempotent). Only this + // server does it - see BotServerPartition.IsGenerator; the others find the accounts once + // they exist and retry the spawns of their own share meanwhile. + var created = await generator.EnsureBotsAsync(configuration.NumberOfAccounts, configuration.MaxCharactersPerAccount).ConfigureAwait(false); + if (created > 0) + { + logger.LogInformation("Generated {Created} new bot account(s).", created); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to generate the bot population."); } - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to generate the bot population."); } var charactersPerAccount = Math.Clamp( @@ -157,7 +140,7 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu var started = 0; var total = 0; - for (var i = 1; i <= configuration.NumberOfAccounts; i++) + for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++) { var loginName = BotGenerator.GetLoginName(i); for (byte slot = 0; slot < charactersPerAccount; slot++) @@ -165,10 +148,16 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu total++; try { - if (await this._botManager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false)) + if (await state.Manager.SpawnBotAsync(gameContext, loginName, slot).ConfigureAwait(false)) { started++; } + else + { + // The account may just not be generated yet (another server is generating the + // population right now) - the maintenance pass retries it. + state.PendingRespawns.Enqueue((loginName, slot)); + } } catch (Exception ex) { @@ -177,19 +166,23 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu } } - // The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot) accounts. - foreach (var loginName in configuration.ParseProofOfConceptAccounts()) + // The proof-of-concept accounts remain an optional extra hook to animate existing (non-bot) + // accounts. They are not part of the partitioned population, so only one server animates them. + if (partition.IsGenerator) { - try + foreach (var loginName in configuration.ParseProofOfConceptAccounts()) { - if (await this._botManager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false)) + try { - started++; + if (await state.Manager.SpawnBotAsync(gameContext, loginName).ConfigureAwait(false)) + { + started++; + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName); } - } - catch (Exception ex) - { - logger.LogError(ex, "Failed to spawn proof-of-concept bot for account '{LoginName}'.", loginName); } } @@ -197,7 +190,7 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu try { - await this._botManager.FormPartiesAsync(gameContext).ConfigureAwait(false); + await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false); } catch (Exception ex) { @@ -208,7 +201,10 @@ private async ValueTask SpawnPopulationAsync(GameContext gameContext, BotConfigu /// public void ForceStart() { - this._nextRunUtc = DateTime.UtcNow; + foreach (var state in this._states.Values) + { + state.NextRunUtc = DateTime.UtcNow; + } } /// @@ -227,9 +223,9 @@ private static BotConfiguration CreateDefaultConfiguration() /// the population ebbs and flows smoothly over the day) and an hourly party re-formation which /// groups bots that lost or never had a party (e.g. after rotating back in). /// - private async ValueTask RunMaintenanceAsync(GameContext gameContext) + private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState state) { - if (DateTime.UtcNow < this._nextMaintenanceUtc) + if (DateTime.UtcNow < state.NextMaintenanceUtc) { return; } @@ -238,7 +234,7 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) // which takes longer than its interval (spawning a bot loads a whole account from the database) // would otherwise overlap with itself: two passes restarting the same bot, rotating the presence // twice, forming parties in parallel. One pass at a time - like the startup below. - if (Interlocked.CompareExchange(ref this._maintenanceRunning, 1, 0) != 0) + if (Interlocked.CompareExchange(ref state.MaintenanceRunning, 1, 0) != 0) { return; } @@ -246,7 +242,7 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); try { - this._nextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; + state.NextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; var configuration = this.Configuration; if (configuration?.Enabled != true) @@ -254,18 +250,18 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) return; } - await this.RespawnPendingAsync(gameContext).ConfigureAwait(false); - await this.EvolveDueMastersAsync(gameContext, logger).ConfigureAwait(false); + await this.RespawnPendingAsync(gameContext, state).ConfigureAwait(false); + await this.EvolveDueMastersAsync(gameContext, state, logger).ConfigureAwait(false); if (configuration.PresenceRotation) { - await this.RotatePresenceAsync(gameContext, configuration, logger).ConfigureAwait(false); + await this.RotatePresenceAsync(gameContext, state, configuration, logger).ConfigureAwait(false); } - if (DateTime.UtcNow >= this._nextPartyReformUtc) + if (DateTime.UtcNow >= state.NextPartyReformUtc) { - this._nextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; - await this._botManager.FormPartiesAsync(gameContext).ConfigureAwait(false); + state.NextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; + await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false); } } catch (Exception ex) @@ -274,7 +270,7 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) } finally { - Interlocked.Exchange(ref this._maintenanceRunning, 0); + Interlocked.Exchange(ref state.MaintenanceRunning, 0); } } @@ -285,9 +281,9 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext) /// restarted right away (see ), which must not happen /// from within one of its own timer callbacks. /// - private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger logger) + private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ServerState state, ILogger logger) { - foreach (var bot in this._botManager.Bots) + foreach (var bot in state.Manager.Bots) { // Not while it hunts with a human (the restart would desert the group), sits in an NPC // dialog or lies dead - like a due reset, the evolution simply happens on a later pass. @@ -300,7 +296,7 @@ private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger l if (bot.AwaitsMasterRestart) { - await this.RestartEvolvedBotAsync(gameContext, bot, logger).ConfigureAwait(false); + await this.RestartEvolvedBotAsync(gameContext, state, bot, logger).ConfigureAwait(false); continue; } @@ -327,7 +323,7 @@ private async ValueTask EvolveDueMastersAsync(GameContext gameContext, ILogger l /// experience rate, master points per level) and the master level stat are only mounted when a /// character enters the world (see ). /// - private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, BotPlayer bot, ILogger logger) + private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, ServerState state, BotPlayer bot, ILogger logger) { // Captured before the restart - the disposed bot loses its account and character. var loginName = bot.Account?.LoginName; @@ -335,7 +331,7 @@ private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, BotPlaye bot.AwaitsMasterRestart = false; try { - if (!await this._botManager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) + if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) && loginName is not null && characterSlot is { } slot) { @@ -343,7 +339,7 @@ private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, BotPlaye // drops the entry when the bot is (still or again) online, so a kept-alive old // instance or a rotation comeback doesn't get doubled. logger.LogWarning("Evolved bot '{Name}' could not be respawned right away; retrying on the next pass.", bot.Name); - this._pendingRespawns.Enqueue((loginName, slot)); + state.PendingRespawns.Enqueue((loginName, slot)); } } catch (Exception ex) @@ -355,27 +351,36 @@ private async ValueTask RestartEvolvedBotAsync(GameContext gameContext, BotPlaye /// /// Retries bringing back bots whose respawn after the master evolution failed. /// - private async ValueTask RespawnPendingAsync(GameContext gameContext) + private async ValueTask RespawnPendingAsync(GameContext gameContext, ServerState state) { - var count = this._pendingRespawns.Count; - for (var i = 0; i < count && this._pendingRespawns.TryDequeue(out var entry); i++) + var count = state.PendingRespawns.Count; + for (var i = 0; i < count && state.PendingRespawns.TryDequeue(out var entry); i++) { - if (this._botManager.IsActive(entry.Login, entry.Slot)) + if (state.Manager.IsActive(entry.Login, entry.Slot)) { continue; } - if (!await this._botManager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false)) + if (!await state.Manager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false)) { - this._pendingRespawns.Enqueue(entry); + state.PendingRespawns.Enqueue(entry); } } } - private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfiguration configuration, ILogger logger) + /// + /// Rotates the presence of the bots THIS server animates (see ): + /// each server keeps the daily curve within its own share of the population. + /// + private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState state, BotConfiguration configuration, ILogger logger) { + if (state.Partition is not { AccountCount: > 0 } partition) + { + return; + } + var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount(); - var totalPopulation = configuration.NumberOfAccounts * charactersPerAccount; + var totalPopulation = partition.AccountCount * charactersPerAccount; if (totalPopulation <= 0) { return; @@ -384,18 +389,18 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfigur var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0; var activity = ActivityByHour[DateTime.Now.Hour]; var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity))); - var online = this._botManager.Bots.Count; + var online = state.Manager.Bots.Count; if (online < targetOnline) { // Bring one bot online: pick a random character which is currently offline. var offline = new List<(string Login, byte Slot)>(); - for (var i = 1; i <= configuration.NumberOfAccounts; i++) + for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++) { var loginName = BotGenerator.GetLoginName(i); for (byte slot = 0; slot < charactersPerAccount; slot++) { - if (!this._botManager.IsActive(loginName, slot)) + if (!state.Manager.IsActive(loginName, slot)) { offline.Add((loginName, slot)); } @@ -403,14 +408,14 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfigur } if (offline.SelectRandom() is { Login: not null } candidate - && await this._botManager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false)) + && await state.Manager.SpawnBotAsync(gameContext, candidate.Login, candidate.Slot).ConfigureAwait(false)) { logger.LogInformation("Bot presence rotation: +1 (online {Online}/{Target} of {Total}).", online + 1, targetOnline, totalPopulation); } } else if (online > targetOnline) { - var stopped = await this._botManager.StopRandomBotAsync().ConfigureAwait(false); + var stopped = await state.Manager.StopRandomBotAsync().ConfigureAwait(false); if (stopped is not null) { logger.LogInformation("Bot presence rotation: -1 '{Name}' (online {Online}/{Target} of {Total}).", stopped, online - 1, targetOnline, totalPopulation); @@ -418,6 +423,10 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, BotConfigur } } + /// + /// The bot feature's state of ONE game server: its own population share, its own bots, and its own + /// startup and maintenance schedule (see ). + /// /// /// Persists the current configuration back to its row, so a /// programmatic change (e.g. clearing the reset flag) survives a restart. @@ -446,4 +455,68 @@ private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotCo logger.LogError(ex, "Failed to persist the bot plugin configuration."); } } + + /// + /// The bot feature's state of ONE game server: its own share of the population, its own bots, and + /// its own startup and maintenance schedule (see ). + /// + private sealed class ServerState + { + /// + /// 0 = startup not run yet, 1 = startup in progress, 2 = startup done (maintenance mode). + /// The periodic task timer fires every second WITHOUT awaiting the previous invocation, so + /// during the minutes-long generation/spawn further ticks arrive concurrently - they must + /// neither re-enter the startup nor run the maintenance (e.g. the presence rotation) against + /// a half-spawned population. Interlocked-updated, hence a field. + /// + private int _startupState; + + /// + /// 1 while a maintenance pass runs, so the next timer tick doesn't start a second one in + /// parallel. Interlocked-updated, hence a field. + /// + private int _maintenanceRunning; + + /// + /// Gets the bots this server animates. + /// + public BotManager Manager { get; } = new(); + + /// + /// Gets the bots whose spawn failed - the account may not be generated yet (another game server + /// is generating the population), or a respawn after the master evolution did not go through. + /// Retried on the following maintenance passes. + /// + public ConcurrentQueue<(string Login, byte Slot)> PendingRespawns { get; } = new(); + + /// + /// Gets or sets the share of the bot population which this server animates. + /// + public BotServerPartition? Partition { get; set; } + + /// + /// Gets or sets the time of this server's (single) startup pass. + /// + public DateTime NextRunUtc { get; set; } = DateTime.UtcNow + StartupDelay; + + /// + /// Gets or sets the time of this server's next maintenance pass. + /// + public DateTime NextMaintenanceUtc { get; set; } = DateTime.UtcNow + StartupDelay + StartupDelay; + + /// + /// Gets or sets the time of this server's next bot party re-formation. + /// + public DateTime NextPartyReformUtc { get; set; } = DateTime.UtcNow + PartyReformInterval; + + /// + /// Gets a reference to the startup state, for the interlocked transitions of the startup pass. + /// + public ref int StartupState => ref this._startupState; + + /// + /// Gets a reference to the maintenance flag, for the interlocked guard of the maintenance pass. + /// + public ref int MaintenanceRunning => ref this._maintenanceRunning; + } } diff --git a/src/GameLogic/Bots/BotServerPartition.cs b/src/GameLogic/Bots/BotServerPartition.cs new file mode 100644 index 000000000..8ec1218c9 --- /dev/null +++ b/src/GameLogic/Bots/BotServerPartition.cs @@ -0,0 +1,186 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.DataModel.Configuration; + +/// +/// Splits the bot population over the game servers of the deployment, so a server does not animate the +/// whole population by itself: bots count towards the player count of their server, and a server which +/// is full turns real clients away - the bots would lock the players out of the game. +/// +/// Which accounts a server animates is a pure function of the account index and the SET of configured +/// game servers, so every server computes the same answer without asking the others - no coordination, +/// no shared state, and it holds in a deployment where each game server is its own process. The share +/// of a server is proportional to its capacity, and only a part of that capacity +/// () is handed to the bots: the rest stays reserved +/// for real players, who must never be denied a slot by a bot. +/// +/// +/// The split is computed when the server starts. Adding a game server to a running deployment therefore +/// takes a restart before the population spreads onto it; that is deliberate. Moving the ownership of a +/// bot between two RUNNING servers would mean one server animating an account the other one is still +/// animating - the very cross-context situation which corrupts a character. +/// +/// +internal sealed class BotServerPartition +{ + private BotServerPartition(int firstAccount, int accountCount, bool isGenerator) + { + this.FirstAccount = firstAccount; + this.AccountCount = accountCount; + this.IsGenerator = isGenerator; + } + + /// + /// Gets the one-based index of the first bot account this server animates. + /// + public int FirstAccount { get; } + + /// + /// Gets the number of bot accounts this server animates. + /// + public int AccountCount { get; } + + /// + /// Gets a value indicating whether this server generates the bot population. Exactly one server + /// does it (the one which animates the first account), so the generation of the accounts - and of + /// their unique character names - never runs twice at the same time. The other servers simply find + /// their accounts once they exist; until then, their spawns are retried by the maintenance pass. + /// + public bool IsGenerator { get; } + + /// + /// Determines the share of the bot population which the given game server animates. + /// + /// The context of the game server which asks. + /// The bot configuration. + /// The logger. + /// The share of this server. + public static async ValueTask CreateAsync(IGameContext gameContext, BotConfiguration configuration, ILogger logger) + { + var requestedAccounts = Math.Max(configuration.NumberOfAccounts, 0); + var charactersPerAccount = configuration.GetEffectiveCharactersPerAccount(); + var capacities = await GetAccountCapacitiesAsync(gameContext, configuration, charactersPerAccount, logger).ConfigureAwait(false); + if (gameContext is not IGameServerContext serverContext || capacities.Count == 0) + { + // A deployment we cannot split (no server definitions readable, or a context which is not a + // game server, e.g. in tests): behave exactly like before - this server animates everything. + return new BotServerPartition(1, requestedAccounts, true); + } + + var (partition, assignedAccounts) = Split(capacities, serverContext.Id, requestedAccounts); + if (assignedAccounts < requestedAccounts) + { + logger.LogWarning( + "The bot population does not fit: {Requested} account(s) configured, but only {Fitting} fit into {Percent}% of the game servers' capacity. {Dropped} account(s) stay offline - raise the servers' maximum player count, the bot capacity share, or lower the number of accounts.", + requestedAccounts, + assignedAccounts, + configuration.GetEffectiveBotCapacityPercent(), + requestedAccounts - assignedAccounts); + } + + logger.LogInformation( + "This game server ({ServerId}) animates {Count} bot account(s) ({First}..{Last}) of {Requested}.", + serverContext.Id, + partition.AccountCount, + partition.AccountCount == 0 ? 0 : partition.FirstAccount, + partition.AccountCount == 0 ? 0 : partition.FirstAccount + partition.AccountCount - 1, + requestedAccounts); + + return partition; + } + + /// + /// Determines whether this server animates the bot account with the given one-based index. + /// + /// The one-based bot account index. + public bool Owns(int accountIndex) + => accountIndex >= this.FirstAccount && accountIndex < this.FirstAccount + this.AccountCount; + + /// + /// Hands the accounts to the servers, each getting a share PROPORTIONAL to its capacity: the pure + /// decision behind . Every server runs it over the same list and gets the + /// same answer, which is what makes the split need no coordination at all. + /// + /// Proportional, not first-come: filling one server to the brim before using the next would leave the + /// added server empty until the first one overflows - and a player on it would meet nobody. The bots + /// are there to populate the world, so they spread over the servers the players can choose from. + /// + /// + /// How many accounts each game server may animate, ordered by server id. + /// The id of the server which asks. + /// The configured number of bot accounts. + /// The share of the asking server, and how many accounts fit into the deployment at all. + internal static (BotServerPartition Partition, int AssignedAccounts) Split( + IEnumerable<(byte ServerId, int Capacity)> capacities, + byte serverId, + int requestedAccounts) + { + var servers = capacities.Where(c => c.Capacity > 0).ToList(); + var totalCapacity = servers.Sum(server => (long)server.Capacity); + if (totalCapacity == 0 || requestedAccounts <= 0) + { + return (new BotServerPartition(1, 0, false), 0); + } + + // What does not fit into the servers' share stays offline; those accounts wake up as soon as the + // deployment offers the room (another game server, a higher player limit or bot capacity share). + var assignedAccounts = (int)Math.Min(requestedAccounts, totalCapacity); + + var partition = new BotServerPartition(1, 0, false); + long capacitySoFar = 0; + var accountsSoFar = 0; + foreach (var (currentServer, capacity) in servers) + { + capacitySoFar += capacity; + + // Walk the cumulative capacity, so the rounding of one server's share is corrected by the + // next one instead of adding up: the shares always sum up to the assigned accounts exactly. + var accountsUpToHere = (int)(assignedAccounts * capacitySoFar / totalCapacity); + var share = accountsUpToHere - accountsSoFar; + if (currentServer == serverId && share > 0) + { + // The server which owns the first account generates the population. + partition = new BotServerPartition(accountsSoFar + 1, share, accountsSoFar == 0); + } + + accountsSoFar = accountsUpToHere; + } + + return (partition, assignedAccounts); + } + + /// + /// Reads how many bot ACCOUNTS each configured game server may animate: its maximum player count, + /// reduced to the bots' share of it, divided by the characters an account animates at once. The + /// servers are ordered by their id, so every server walks the same list in the same order. + /// + private static async ValueTask> GetAccountCapacitiesAsync( + IGameContext gameContext, + BotConfiguration configuration, + int charactersPerAccount, + ILogger logger) + { + try + { + using var context = gameContext.PersistenceContextProvider.CreateNewConfigurationContext(); + var definitions = await context.GetAsync().ConfigureAwait(false); + var capacityPercent = configuration.GetEffectiveBotCapacityPercent(); + return definitions + .OrderBy(definition => definition.ServerID) + .Select(definition => ( + definition.ServerID, + Capacity: (definition.ServerConfiguration?.MaximumPlayers ?? 0) * capacityPercent / 100 / charactersPerAccount)) + .ToList(); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not read the game server definitions; this server animates the whole bot population."); + return []; + } + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs b/tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs new file mode 100644 index 000000000..3369a9c0b --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotServerPartitionTest.cs @@ -0,0 +1,123 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests how the bot population is split over the game servers of a deployment +/// (): bots count towards the player limit of their server, so a server +/// must never animate more of them than its reserved share allows - and the servers must agree on who +/// animates whom without asking each other. +/// +[TestFixture] +public class BotServerPartitionTest +{ + /// + /// A single game server animates the accounts which fit into its share; the rest stays offline + /// instead of filling the server up, because the remaining capacity belongs to the players. + /// + [Test] + public void SingleServerTakesWhatFits() + { + var (partition, assigned) = BotServerPartition.Split([(0, 120)], 0, 220); + + Assert.That(partition.FirstAccount, Is.EqualTo(1)); + Assert.That(partition.AccountCount, Is.EqualTo(120)); + Assert.That(partition.IsGenerator, Is.True); + Assert.That(assigned, Is.EqualTo(120)); + } + + /// + /// The scenario the split is made for: one server was crowded, a second one is added, and the + /// population spreads over BOTH of them - a player who picks the new server meets bots there, too. + /// + [Test] + public void PopulationSpreadsOverBothServers() + { + List<(byte ServerId, int Capacity)> capacities = [(0, 120), (1, 120)]; + + var (first, assigned) = BotServerPartition.Split(capacities, 0, 220); + var (second, _) = BotServerPartition.Split(capacities, 1, 220); + + Assert.That(assigned, Is.EqualTo(220)); + Assert.That(first.AccountCount, Is.EqualTo(110)); + Assert.That(second.AccountCount, Is.EqualTo(110)); + Assert.That(second.FirstAccount, Is.EqualTo(111)); + } + + /// + /// The invariant which protects the characters: every account is animated by exactly one server. + /// Two servers animating one account is the cross-context situation which corrupts it. + /// + /// The configured number of bot accounts. + [TestCase(1)] + [TestCase(7)] + [TestCase(220)] + [TestCase(1000)] + public void EveryAccountIsAnimatedExactlyOnce(int requestedAccounts) + { + // Deliberately uneven capacities, so the rounding of the shares is exercised. + List<(byte ServerId, int Capacity)> capacities = [(0, 37), (1, 90), (2, 113)]; + var partitions = capacities + .Select(server => BotServerPartition.Split(capacities, server.ServerId, requestedAccounts)) + .ToList(); + var assigned = partitions[0].AssignedAccounts; + + Assert.That(partitions.Sum(p => p.Partition.AccountCount), Is.EqualTo(assigned)); + for (var account = 1; account <= assigned; account++) + { + Assert.That(partitions.Count(p => p.Partition.Owns(account)), Is.EqualTo(1), $"account {account}"); + } + } + + /// + /// Exactly one server generates the population, so the accounts - and their unique character names - + /// are never created twice at the same time. + /// + [Test] + public void OnlyTheFirstServerGenerates() + { + List<(byte ServerId, int Capacity)> capacities = [(0, 50), (1, 50), (2, 50)]; + + Assert.That(BotServerPartition.Split(capacities, 0, 150).Partition.IsGenerator, Is.True); + Assert.That(BotServerPartition.Split(capacities, 1, 150).Partition.IsGenerator, Is.False); + Assert.That(BotServerPartition.Split(capacities, 2, 150).Partition.IsGenerator, Is.False); + } + + /// + /// The servers may have different player limits; the shares follow their capacity. + /// + [Test] + public void SharesFollowTheServerCapacity() + { + List<(byte ServerId, int Capacity)> capacities = [(0, 30), (1, 90)]; + + var (small, _) = BotServerPartition.Split(capacities, 0, 120); + var (big, _) = BotServerPartition.Split(capacities, 1, 120); + + Assert.That(small.AccountCount, Is.EqualTo(30)); + Assert.That(big.AccountCount, Is.EqualTo(90)); + Assert.That(big.FirstAccount, Is.EqualTo(31)); + } + + /// + /// A server without any bot capacity (its player limit is reserved for players entirely) animates + /// nothing, and the other servers still cover the whole population. + /// + [Test] + public void ServerWithoutCapacityAnimatesNothing() + { + List<(byte ServerId, int Capacity)> capacities = [(0, 100), (1, 0)]; + + var (empty, assigned) = BotServerPartition.Split(capacities, 1, 60); + + Assert.That(empty.AccountCount, Is.EqualTo(0)); + Assert.That(empty.IsGenerator, Is.False); + Assert.That(empty.Owns(1), Is.False); + Assert.That(assigned, Is.EqualTo(60)); + Assert.That(BotServerPartition.Split(capacities, 0, 60).Partition.AccountCount, Is.EqualTo(60)); + } +} From 209b214e66b68c5d3f36a9ea4dd0d58111d6c6da Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 13:15:56 +0200 Subject: [PATCH 43/60] Remove dead code from two bot tests An unused local and an unused parameter, both reported by the static analysis. The calls stay: the stronger piece of gear has to be equipped for the jewel test to mean anything, and the capacity callback keeps its signature. --- tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs | 4 +++- tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs index 59007daf1..31a38d5e1 100644 --- a/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs +++ b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs @@ -28,7 +28,9 @@ public class BotJewelHandlerTest public async ValueTask PrefersBlessOnWeakestUpgradeableItemAsync() { var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); - var strongPiece = await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 4).ConfigureAwait(false); + + // The stronger piece is the one the bot must NOT pick, so it only has to be there. + await AddEquippedItemAsync(player, InventoryConstants.LeftHandSlot, 4).ConfigureAwait(false); var weakPiece = await AddEquippedItemAsync(player, InventoryConstants.RightHandSlot, 2).ConfigureAwait(false); var bless = await AddJewelAsync(player, FirstBackpackSlot, ItemConstants.JewelOfBless).ConfigureAwait(false); await AddJewelAsync(player, FirstBackpackSlot + 1, ItemConstants.JewelOfSoul).ConfigureAwait(false); diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs index fd943b8eb..013658626 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs @@ -53,7 +53,9 @@ public void SplitPoints_CappedStatOverflowsToOthers() public void SplitPoints_AllCapped_LeavesPointsUnassigned() { var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 40) }; - long CapacityOf(AttributeDefinition stat) => 25; + + // Every stat is capped at the same value, so which one is asked for does not matter. + long CapacityOf(AttributeDefinition _) => 25; var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount); From 2b2ef4b51758a65256db6490f61428c4c4e5527e Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 13:42:17 +0200 Subject: [PATCH 44/60] Use a lambda for the capacity callback in a bot test The static analysis kept flagging the unused parameter of the local function, which cannot go: the callback signature belongs to SplitPoints. A lambda says the same thing and does not pretend the parameter matters. --- tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs index 013658626..291311d7c 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs @@ -55,9 +55,9 @@ public void SplitPoints_AllCapped_LeavesPointsUnassigned() var weights = new[] { (Stats.BaseStrength, 60), (Stats.BaseAgility, 40) }; // Every stat is capped at the same value, so which one is asked for does not matter. - long CapacityOf(AttributeDefinition _) => 25; + Func capacityOf = _ => 25; - var result = BotProgression.SplitPoints(1000, weights, CapacityOf).ToDictionary(r => r.Stat, r => r.Amount); + var result = BotProgression.SplitPoints(1000, weights, capacityOf).ToDictionary(r => r.Stat, r => r.Amount); Assert.That(result.Values.Sum(), Is.EqualTo(50)); Assert.That(result.Values, Is.All.EqualTo(25)); From 0bcc69f0c914e62b7884f77eb882adaa2f99a38d Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 14:29:43 +0200 Subject: [PATCH 45/60] Restart a bot whose AI keeps failing The attribute system is not thread-safe, and a lost race can corrupt a character's attribute graph for good: from then on every AI tick of that bot throws the same exception, the bot stops playing, and it floods the log with a few exceptions per second until the server is restarted. We saw it twice on a 1100-bot run. A bot now counts the ticks failing in a row and, after twenty of them, asks the maintenance pass to restart it - a fresh login rebuilds the attribute graph and heals it, which is what a player would do. A single failing tick is skipped like before; one successful tick forgets the earlier failures. The two hooks on OfflinePlayer do nothing there, so the human offline mode behaves exactly as before. --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 37 ++++++++++++++ src/GameLogic/Bots/BotNavigator.cs | 2 + src/GameLogic/Bots/BotPlayer.cs | 43 ++++++++++++++++ src/GameLogic/Offline/OfflinePlayer.cs | 19 +++++++ .../Offline/OfflinePlayerMuHelper.cs | 2 + .../BotSelfHealingTest.cs | 49 +++++++++++++++++++ 6 files changed, 152 insertions(+) create mode 100644 tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index d27094907..e865e3b1b 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -251,6 +251,7 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState } await this.RespawnPendingAsync(gameContext, state).ConfigureAwait(false); + await this.RestartFaultedBotsAsync(gameContext, state, logger).ConfigureAwait(false); await this.EvolveDueMastersAsync(gameContext, state, logger).ConfigureAwait(false); if (configuration.PresenceRotation) @@ -274,6 +275,42 @@ private async ValueTask RunMaintenanceAsync(GameContext gameContext, ServerState } } + /// + /// Restarts bots whose AI keeps throwing (see ). The + /// engine's attribute system is not thread-safe, and a lost race can corrupt a character's attribute + /// graph for good: the bot stops playing and every following tick throws the same exception, up to a + /// flood of them per second. A fresh login rebuilds the graph and heals it - the same thing a player + /// would do, and the only cure available from outside the engine. Runs from the maintenance pass, + /// which is the only place allowed to restart a bot. + /// + private async ValueTask RestartFaultedBotsAsync(GameContext gameContext, ServerState state, ILogger logger) + { + foreach (var bot in state.Manager.Bots) + { + if (!bot.AwaitsFaultRestart) + { + continue; + } + + var loginName = bot.Account?.LoginName; + var characterSlot = bot.SelectedCharacter?.CharacterSlot; + bot.AwaitsFaultRestart = false; + try + { + if (!await state.Manager.RestartBotAsync(gameContext, bot).ConfigureAwait(false) + && loginName is not null + && characterSlot is { } slot) + { + state.PendingRespawns.Enqueue((loginName, slot)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to restart the faulted bot '{Name}'.", bot.Name); + } + } + } + /// /// Evolves bots which reached the game's maximum level into their master class (see /// for the rules, including the iron rule of reset servers). diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 8157c8e9f..f500cee9f 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -300,6 +300,7 @@ private async Task SafeEvaluateAsync(CancellationToken cancellationToken) try { await this.EvaluateAsync(cancellationToken).ConfigureAwait(false); + this._player.OnAiTickSucceeded(); } catch (OperationCanceledException) { @@ -308,6 +309,7 @@ private async Task SafeEvaluateAsync(CancellationToken cancellationToken) catch (Exception ex) { this._player.Logger.LogError(ex, "Bot navigator error for {Account}.", this._player.AccountLoginName); + this._player.OnAiTickFailed(); } finally { diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs index ae96e4227..9cf8d114f 100644 --- a/src/GameLogic/Bots/BotPlayer.cs +++ b/src/GameLogic/Bots/BotPlayer.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using System.Threading; using MUnique.OpenMU.GameLogic.Offline; /// @@ -13,8 +14,20 @@ namespace MUnique.OpenMU.GameLogic.Bots; /// public sealed class BotPlayer : OfflinePlayer { + /// + /// After this many AI ticks failing in a row, the bot is considered broken and gets restarted. + /// The engine's attribute system is not thread-safe, and a lost race can corrupt a character's + /// attribute graph for good: every following tick throws, the bot stops playing and floods the log + /// with the same exception until the server restarts. A fresh login rebuilds the graph and heals it, + /// which is exactly what a player would do. The threshold is high enough that a single failing tick + /// (a transient race, a monster which just died) is simply skipped, like before. + /// + private const int ConsecutiveFailuresUntilRestart = 20; + private BotNavigator? _navigator; + private int _consecutiveTickFailures; + /// /// Initializes a new instance of the class. /// @@ -36,6 +49,13 @@ public BotPlayer(IGameContext gameContext) /// public bool AwaitsMasterRestart { get; set; } + /// + /// Gets or sets a value indicating whether this bot's AI keeps failing and it has to be restarted + /// (see ). Acted upon by the maintenance pass, which is + /// the only place allowed to restart a bot. + /// + public bool AwaitsFaultRestart { get; set; } + /// public override async ValueTask StopAsync() { @@ -43,6 +63,29 @@ public override async ValueTask StopAsync() await base.StopAsync().ConfigureAwait(false); } + /// + internal override void OnAiTickSucceeded() + { + if (this._consecutiveTickFailures > 0) + { + Interlocked.Exchange(ref this._consecutiveTickFailures, 0); + } + } + + /// + internal override void OnAiTickFailed() + { + if (Interlocked.Increment(ref this._consecutiveTickFailures) == ConsecutiveFailuresUntilRestart) + { + // Only when the counter HITS the threshold, so the log gets one line, not one per tick. + this.Logger.LogWarning( + "Bot '{Name}' failed {Count} AI ticks in a row and gets restarted to heal it.", + this.Name, + ConsecutiveFailuresUntilRestart); + this.AwaitsFaultRestart = true; + } + } + /// protected override void StartIntelligence() { diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 4bebf899f..5eda749b5 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -372,6 +372,25 @@ internal async ValueTask DrainPendingBotActionsAsync() } } + /// + /// Called when an AI tick of this player finished without an exception. Does nothing here - a bot + /// uses it to forget earlier failures (see ). + /// + internal virtual void OnAiTickSucceeded() + { + // Nothing to do for a plain offline player. + } + + /// + /// 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 ). + /// + internal virtual void OnAiTickFailed() + { + // Nothing to do for a plain offline player. + } + /// protected override async ValueTask InternalDisconnectAsync() { diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index 9ae85bdce..73bc9a13e 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -142,6 +142,7 @@ private async Task SafeTickAsync(CancellationToken cancellationToken) try { await this.TickAsync(cancellationToken).ConfigureAwait(false); + this._player.OnAiTickSucceeded(); } catch (OperationCanceledException) { @@ -150,6 +151,7 @@ private async Task SafeTickAsync(CancellationToken cancellationToken) catch (Exception ex) { this._player.Logger.LogError(ex, "Error in offline player helper tick for {AccountLoginName}.", this._player.AccountLoginName); + this._player.OnAiTickFailed(); } } diff --git a/tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs b/tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs new file mode 100644 index 000000000..3db8b3e3c --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotSelfHealingTest.cs @@ -0,0 +1,49 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + +using MUnique.OpenMU.GameLogic.Bots; + +/// +/// Tests how a bot reacts to its AI failing: the engine's attribute system is not thread-safe, and a +/// lost race can corrupt a character's attribute graph for good - from then on every tick throws, the +/// bot stops playing and floods the log. It asks for a restart, which rebuilds the graph and heals it. +/// +[TestFixture] +public class BotSelfHealingTest +{ + /// + /// A tick failing now and then is simply skipped, like before - no restart. + /// + [Test] + public void SingleFailuresDoNotRestartTheBot() + { + var bot = new BotPlayer(GameContextTestHelper.CreateGameContext()); + + for (var i = 0; i < 100; i++) + { + bot.OnAiTickFailed(); + bot.OnAiTickSucceeded(); + } + + Assert.That(bot.AwaitsFaultRestart, Is.False); + } + + /// + /// A bot whose ticks keep failing is broken and asks the maintenance pass to restart it. + /// + [Test] + public void PersistentFailuresRestartTheBot() + { + var bot = new BotPlayer(GameContextTestHelper.CreateGameContext()); + + for (var i = 0; i < 20; i++) + { + bot.OnAiTickFailed(); + } + + Assert.That(bot.AwaitsFaultRestart, Is.True); + } +} From 91659639aebf0b82b5706386210311a4dd67baad Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 14:29:55 +0200 Subject: [PATCH 46/60] Keep master points off the weapons a bot does not use Once the useful picks were exhausted, a bot started spending its master points on strengtheners of weapon types it never carries: a bow bonus for a sword swinger. Those passives target one of the weapon-specific master attributes, so they are now matched against the weapon the bot fights with - what it carries and what its build makes it pick up. The item groups come from the item data: the scepters live in the mace group, the Rage Fighter's gloves in the sword group, and sticks and books next to the staffs. The points go into something useful instead; nothing is held back. --- src/GameLogic/Bots/BotMasterHandler.cs | 108 +++++++++++++++++++++++-- 1 file changed, 101 insertions(+), 7 deletions(-) diff --git a/src/GameLogic/Bots/BotMasterHandler.cs b/src/GameLogic/Bots/BotMasterHandler.cs index 73bb65d44..72daa4209 100644 --- a/src/GameLogic/Bots/BotMasterHandler.cs +++ b/src/GameLogic/Bots/BotMasterHandler.cs @@ -4,6 +4,7 @@ namespace MUnique.OpenMU.GameLogic.Bots; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.DataModel.Configuration; using MUnique.OpenMU.GameLogic.Attributes; using MUnique.OpenMU.GameLogic.Offline; @@ -34,6 +35,21 @@ internal static class BotMasterHandler /// private const int RankUnlockLevel = 10; + /// The item groups of the weapons a master bonus can be tied to (see ). + private const byte SwordGroup = 0; + + /// The item group of the maces - the scepters live here as well. + private const byte MaceGroup = 2; + + /// The item group of the spears. + private const byte SpearGroup = 3; + + /// The item group of the bows and crossbows. + private const byte BowGroup = 4; + + /// The item group of the staffs - the sticks and books live here as well. + private const byte StaffGroup = 5; + private static readonly AddMasterPointAction AddPointAction = new(); /// @@ -180,7 +196,7 @@ public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player) && s.QualifiedCharacters.Contains(characterClass) && character.LearnedSkills.All(l => l.Skill != s) && CanLearn(player, s, character.MasterLevelUpPoints)) - .OrderBy(s => IsUsefulPick(s, skillList) ? 0 : 1) + .OrderBy(s => IsUsefulPick(player, s, skillList) ? 0 : 1) .ThenBy(s => s.MasterDefinition!.Rank) .ThenBy(s => s.Number) .FirstOrDefault() is { } newSkill) @@ -190,7 +206,7 @@ public static async ValueTask TrySpendMasterPointsAsync(OfflinePlayer player) return learned .Where(l => l.Level < l.Skill!.MasterDefinition!.MaximumLevel) - .OrderBy(l => IsUsefulPick(l.Skill!, skillList) ? 0 : 1) + .OrderBy(l => IsUsefulPick(player, l.Skill!, skillList) ? 0 : 1) .ThenBy(l => l.Skill!.MasterDefinition!.Rank) .ThenBy(l => l.Skill!.Number) .FirstOrDefault()?.Skill; @@ -232,13 +248,91 @@ private static bool CanLearn(Player player, Skill skill, int availablePoints) /// /// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat, - /// or a strengthener/mastery of a skill the bot actually has in its list. The rest (e.g. weapon - /// strengtheners of weapon types the bot doesn't carry) still gets filled, just last. + /// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON + /// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a + /// sword) - those get filled last, after everything which actually helps. /// - private static bool IsUsefulPick(Skill skill, ISkillList skillList) + private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList) { var definition = skill.MasterDefinition!; - return definition.TargetAttribute is not null - || (definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number)); + if (definition.TargetAttribute is { } target) + { + return WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup); + } + + return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number); + } + + /// + /// The item group of the weapon a master bonus attribute belongs to, or null when the bonus + /// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from + /// the item data: scepters live in the mace group, the Rage Fighter's gloves in the sword group, and + /// sticks and books next to the staffs. + /// + private static byte? WeaponGroupOfBonus(AttributeDefinition attribute) + { + if (attribute == Stats.OneHandedSwordBonusDamage + || attribute == Stats.TwoHandedSwordStrBonusDamage + || attribute == Stats.TwoHandedSwordMasteryBonusDamage + || attribute == Stats.GloveWeaponBonusDamage) + { + return SwordGroup; + } + + if (attribute == Stats.MaceBonusDamage + || attribute == Stats.MaceMasteryStunChance + || attribute == Stats.ScepterStrBonusDamage + || attribute == Stats.ScepterMasteryBonusDamage + || attribute == Stats.ScepterPetBonusDamage + || attribute == Stats.BonusDamageWithScepterCmdDiv) + { + return MaceGroup; + } + + if (attribute == Stats.SpearBonusDamage + || attribute == Stats.SpearMasteryDoubleDamageChance) + { + return SpearGroup; + } + + if (attribute == Stats.BowStrBonusDamage + || attribute == Stats.CrossBowStrBonusDamage + || attribute == Stats.CrossBowMasteryBonusDamage) + { + return BowGroup; + } + + if (attribute == Stats.OneHandedStaffBonusBaseDamage + || attribute == Stats.TwoHandedStaffBonusBaseDamage + || attribute == Stats.TwoHandedStaffMasteryBonusDamage + || attribute == Stats.StickBonusBaseDamage + || attribute == Stats.StickMasteryBonusDamage + || attribute == Stats.BookBonusBaseDamage) + { + return StaffGroup; + } + + return null; + } + + /// + /// Whether the bot fights with a weapon of this item group - what it carries right now, and what its + /// build makes it pick up in the future (see ), so + /// a bot which is momentarily unarmed does not start collecting bonuses for the wrong weapon. + /// + private static bool CarriesWeaponOfGroup(Player player, byte weaponGroup) + { + if (player.Inventory?.GetItem(InventoryConstants.LeftHandSlot)?.Definition is { } weapon + && weapon.Group == weaponGroup) + { + return true; + } + + return player.SelectedCharacter is { CharacterClass: { } characterClass } character + && BotProgression.IsPreferredWeaponGroup( + characterClass, + character.Name, + BotResetHandler.GetResetConfiguration(player.GameContext) is not null, + weaponGroup); } } From 98e0b5951f5ed3366570c75313db6e35568c114a Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 14:29:55 +0200 Subject: [PATCH 47/60] Document the bot feature How it works, what the bots do, what they were validated with, the known limitations, and how to enable them on a server. --- docs/Bots.md | 240 +++++++++++++++++++++++++++++++++++++++++++++++++ docs/Readme.md | 3 + 2 files changed, 243 insertions(+) create mode 100644 docs/Bots.md diff --git a/docs/Bots.md b/docs/Bots.md new file mode 100644 index 000000000..f55e73d25 --- /dev/null +++ b/docs/Bots.md @@ -0,0 +1,240 @@ +# Server-side AI Bots + +Bots are persistent, autonomous characters which populate a server like real +players: they hunt with the skills of their class, level up, spend their points, +keep their buffs up, pick up and wear better gear, restock in town, group up, +defend themselves, and come and go over the day. A player who meets one should +not be able to tell it apart from a quiet human player. + +They are driven entirely by the server. No game client is involved and no +packets are exchanged: a bot is a connection-less `OfflinePlayer` — the same +class which keeps a character playing after its owner logs out — with a +navigator on top which gives it a life of its own. + +The feature is disabled by default. Enabling it is always a deliberate act of +the server admin. + +## How it works + +**Bots are ordinary accounts.** Each one is a regular `Account` with the `IsBot` +flag, holding up to five characters with generated names, levels, classes, +stats, skills and starter gear. They are created once, saved like any other +character, and reloaded on every start — a bot's progress belongs to the +server's data, not to a process. Every bot animates one character in its own +persistence context, so the characters of one account can play at the same time. + +**Two ticks make up the mind of a bot.** The offline MU Helper AI runs twice a +second and does what it does for a human's offline session: attack, heal, buff, +pick items up. On top of it, a bot navigator runs every second and decides the +things an offline session never had to: where to hunt, when to travel or warp, +when to go shopping, whom to follow. Everything a bot changes about itself — +equipping, jewels, resets, master points — is queued into the AI tick, so it +never runs while the combat handler is working on the same character. + +**Bots act through the regular player actions.** Moving an item, talking to a +merchant, consuming a jewel, entering an event: a bot goes through the same +actions with the same validations a client's packet would trigger. It cannot do +anything a player could not do, and rule changes apply to bots for free. + +**The population is split over the game servers.** Bots count towards the player +count of their server exactly like players do, and a server which reached its +maximum player count turns new clients away — so a population large enough to +fill a server would lock the players out of it. `Bot capacity %` (60 by default) +is the share of a server's player limit its bots may occupy; the rest stays +reserved for the players. Which accounts a server animates is a pure function of +the account index and the set of configured game servers, so every server +computes the same split without asking the others — which also holds when each +game server runs as its own process. Exactly one server generates the +population, so accounts and character names are never created twice. Accounts +which do not fit stay offline until the deployment offers the room for them. + +## Configuration + +The *Bots* feature plugin, in the "Feature Plugins" section of the admin panel: + +- **`Enabled`** — spawns the bots after the server has started. Off by default. +- **`Number of accounts`** — how many bot accounts to maintain. +- **`Characters per account`** — how many characters each account animates at + once (at most five). +- **`Bot capacity %`** — the share of a game server's maximum player count its + bots may occupy; the rest is reserved for the players. +- **`Presence rotation`** — bots log in and out over the day instead of all + being online around the clock. +- **`Min. online share %`** — how much of the population stays online at the + quietest hour. +- **`Bots pay reset costs`** — whether bots pay the configured zen and item + costs for their resets. Off by default: they take no part in the economy those + costs are balanced for. +- **`Reset bots`** — purges and regenerates the whole population on the next + start, then clears itself. + +## What a bot does + +### Hunting and travelling + +A bot hunts where the monsters actually are: it scans its surroundings for live +monsters instead of walking to a spawn point which may be empty. Long distances +are covered with a cached route over the whole map, walked a few steps at a +time, so the bot can stop and fight on the way. + +It only engages what it can survive. The decision is made against the monster's +real damage, defense and attack rate versus the bot's own defense, health and +chance to be hit — a monster's nominal level says little about its punch on the +high-end maps. An agility build's dodge therefore counts as the defense it +really is, and better gear opens tougher maps, exactly like for a player. + +Map access follows the game's warp list: a bot enters a map only if its level +may legally warp there, and a bot which finds itself on a map it may not be on +(after a reset, for instance) leaves for the best map it may use. The map it +reached is persisted, so a restarted bot wakes up where it stopped. + +### Fighting and progressing + +A bot fights with the strongest skill of its class it has learned and can pay +for; casters keep their distance and drink mana. Skills are learned against the +game's own requirements — total energy, leadership, character level — at +generation and again on every level-up, and the class buffs are kept up on their +own. + +Level-up points follow a per-class build modelled on what players actually play: +an agility/shield meta on reset servers, guide-style builds on classic ones, +chosen automatically by whether the reset feature is configured. Classes with +two viable archetypes (a warrior or a wizard Magic Gladiator, a pure or an +energy Blade Knight) roll one per bot, and a stat which hits a server's maximum +overflows into the rest of the build. + +Bots evolve like players do. The second-generation class change happens at level +200 — the same assignment the class-change quest performs — and the master class +at the game's maximum level, followed by a relog, because the master attributes +only mount when a character enters the world. Master points go into the master +skill tree through the regular action, with its rank gates and skill +requirements, preferring passives which boost a stat and strengtheners of skills +the bot actually uses; a bonus tied to a weapon type the bot does not fight with +is never bought. On a server with the reset feature, a bot only masters once its +reset limit is exhausted — while resets remain, resetting is what players do, so +the bots do it too. + +### Items and money + +Dropped gear is judged before it is picked up: a bot collects what it can wear +and what is worth money, and leaves the rest lying. An upgrade is put on through +the regular move-item action, with the whole swap planned first — which slot, +and which pieces have to come off, including the other hand for a two-handed +weapon. If the engine refuses the equip after all, the old gear goes straight +back on. The replaced piece stays in the backpack and is sold on the next trip +to town, rather than being dropped where the next bot would pick it up again. + +When the backpack fills up or the potions run low, the bot walks to a merchant, +sells its junk, and buys refills from the proceeds while the shop dialog visibly +occupies it — on a map without a merchant it warps home first, like a player +would. Looted Jewels of Bless, Soul and Life are spent on its own equipment +through the regular consume action, with the same success rates and failure +penalties a player faces, and with the caution a player shows: a Soul is only +risked where a failure cannot destroy the item's level. + +Wings do not drop, so bots earn them at the classic milestones instead — the +first pair at level 180, the second at 280 and the third, master-only pair at +400. Which class wears which pair comes from the item data, and the outgrown +pair is destroyed rather than dropped. + +### Mini game events + +A bot never enters Blood Castle, Devil Square or Chaos Castle on its own — it +has no ticket and does not farm for one. It enters when a player who leads a +party with bots enters with their own ticket: the leader's entry legitimizes the +visit for the whole group. + +Each bot is checked against the entry restrictions a player faces (the level +bracket, including the separate one for the special characters, the master-class +requirement, the player-killer rule). A bot which does not qualify leaves the +party and goes back to its own life instead of blocking the entry. + +Inside, its open-world routine is suspended: no shopping, no map changes, no +boredom, no grudges. It fights what the event throws at it and keeps up with the +leader. Chaos Castle is a free-for-all, so there the other participants are +targets like everyone else — and a fight inside leaves no grudge outside. A bot +which dies respawns in the safezone like a player, which takes it out of the +event; the survivors are warped out when the event ends. + +### Company and rhythm + +Bots hunt in parties of two to five, grouped by level so the whole party can +hunt the leader's maps. The elf heals, the buffs are shared, the party +experience bonus applies. Parties re-form every hour. + +A player may invite a bot into their own party: it accepts after a human-like +pause of a few seconds, provided the level gap is sane and it is not in the +middle of an errand. A living player takes precedence over the bot's own company +— a bot hunting with other bots leaves them for the inviter, and breaks that bot +party up if it was leading it, so a player never has to guess which bot happens +to be free. In a party the bot follows its leader, defers a due reset, and +eventually leaves politely: when the leader enters a map it may not access, +before its own logout, or simply when it gets bored. + +A bot fights back when a player attacks it, but only as far as the game's own +PvP rules allow: inside the active self-defense window, or against a player +already flagged as a killer. It can therefore never be provoked into becoming an +outlaw that players could farm for free. It remembers who hit it, and a killed +bot walks back to its killer — waiting for a legal opening rather than taking +one. + +Over the day, the presence rotation logs bots in and out: fewest in the early +morning, most in the evening, and never more than one at a time, so the +population ebbs and flows instead of appearing and vanishing in blocks. + +### Keeping itself alive + +The engine's attribute system is not thread-safe, and a lost race can corrupt a +character's attribute graph for good. A bot which hits it stops playing and +throws on every following tick. Rather than leave it lying there, a bot counts +the ticks which fail in a row and, after twenty of them, has itself restarted: a +fresh login rebuilds the attribute graph and heals it — the same thing a player +would do. A single failing tick is skipped, as before. + +## What it costs + +Measured on a 12-core host, as a rough guide for capacity planning: + +| Population | CPU | Memory | +| --- | --- | --- | +| 250 bots | ~0.35 core | ~760 MB | +| 1100 bots | ~1.7 cores | ~1.2 GiB | + +Generating a fresh population costs about a second per account (the password +hash dominates); starting an existing one of 1100 bots takes some 15 seconds. + +## Known limitations + +- **The engine's races are hit more often.** Neither `MagicEffectsList` nor + `ComposableAttribute` is thread-safe, and a thousand bots run into them more + often than human players do: a few caught exceptions per minute. A bot whose + attribute graph gets corrupted restarts itself (see above); a real fix belongs + into the engine, not into the bots. +- **Master skills which cost ten points at once are never learned.** A bot + invests every point as it earns it, so it never holds ten of them, and the + branches of the tree behind such a skill stay untouched. +- **The Summoner's enemy debuffs (Sleep, Weakness, Innovation) are unused.** + Deliberate: they would be cast through the buff rotation, which would have the + bot put itself to sleep. A cast-on-enemy path in the combat handler would be + needed. +- **Bots do no quests and do not trade with players.** Deliberate scope. The + quests which matter for progression (the class changes) are performed + directly, and trading would be an abuse surface. +- **Adding a game server to a running deployment does not spread the bots onto + it before a restart.** Deliberate: moving a bot between two running servers + would animate one account from two persistence contexts, which corrupts the + character. + +## Enabling it on a server + +1. Enable the *Bots* plugin and set the number of accounts. Each account + animates up to five characters, so 50 accounts × 5 = 250 bots. +2. Check that the population fits. The bots of a game server may occupy + `Bot capacity %` of its player limit — with the default of 60 %, a server for + 1000 players hosts up to 600 bots. What does not fit stays offline, and the + plugin says so in the log: raise the player limit, raise the share, or add a + game server, over which the population then spreads by itself. +3. Restart the server. The population is generated on the first start and + reloaded afterwards. +4. To build a fresh population, set `Reset bots`: it purges the old one, + generates a new one, and clears the flag again. diff --git a/docs/Readme.md b/docs/Readme.md index 245b730ef..545675219 100644 --- a/docs/Readme.md +++ b/docs/Readme.md @@ -140,6 +140,9 @@ database (e.g. RavenDB). * [Master Skill System](MasterSystem.md): Description about the master skill system +* [Server-side AI Bots](Bots.md): Description about the bots which populate +the server + * [GameMap](GameMap.md): Description about the GameMap implementation * [Progress](Progress.md): Information about the feature implementation From 48869d6c4cb47b0880e5e63307e20eedde8ad7ab Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 19:16:38 +0200 Subject: [PATCH 48/60] Hunt the monsters which pay a mastered bot A master class at the maximum level only earns master experience from monsters of at least MinimumMonsterLevelForMasterExperience, and no regular experience at all - so below that line a kill pays it nothing. Bots kept hunting the strongest monsters they considered safe, which are weaker than that line, and never gained a single point of master experience. Mastered bots now pick their map and hunting ground among the monsters which pay them, and take the weakest ones above the line: master experience hardly grows with the monster's level, so the cheapest kill above it is the best one. Those monsters carry 40.000+ health, out of reach of the regular hit budget for a bot in the gear it collects from drops, so the budget is stretched for them - a long fight it survives beats a quick one worth nothing. Survivability is not stretched: a monster whose hits it cannot take is still refused. --- docs/Bots.md | 17 +++++++ src/GameLogic/Bots/BotNavigator.cs | 65 +++++++++++++++++++++++--- src/GameLogic/Offline/CombatHandler.cs | 30 +++++++++++- 3 files changed, 105 insertions(+), 7 deletions(-) diff --git a/docs/Bots.md b/docs/Bots.md index f55e73d25..0a1feaef2 100644 --- a/docs/Bots.md +++ b/docs/Bots.md @@ -114,6 +114,18 @@ is never bought. On a server with the reset feature, a bot only masters once its reset limit is exhausted — while resets remain, resetting is what players do, so the bots do it too. +A mastered bot changes what it hunts. Master experience is only granted for +monsters of at least `Minimum monster level for master experience` (95 in the +default configuration), and a character at the maximum level earns nothing else +- so below that line a kill pays a mastered bot nothing at all. It therefore +looks for maps which hold such monsters, and takes the WEAKEST ones above the +line rather than the strongest: master experience hardly grows with the +monster's level, so the cheapest kill above it is the best one. Those monsters +carry 40.000+ health, well beyond the hit budget a bot's usual gear affords, so +the budget is stretched for them - a slow fight it survives beats a quick one +worth nothing. What is not stretched is its survivability: a monster whose hits +the bot cannot take is refused, mastered or not. + ### Items and money Dropped gear is judged before it is picked up: a bot collects what it can wear @@ -217,6 +229,11 @@ hash dominates); starting an existing one of 1100 bots takes some 15 seconds. Deliberate: they would be cast through the buff rotation, which would have the bot put itself to sleep. A cast-on-enemy path in the combat handler would be needed. +- **Bots never buy equipment.** They wear what they find, so their gear lags + behind their level, and a bot at the maximum level is weaker than a player of + the same level would be. It is the reason a mastered bot needs a stretched hit + budget to reach the monsters which pay master experience at all. Letting bots + spend their money on gear would close the loop; they earn plenty of it. - **Bots do no quests and do not trade with players.** Deliberate scope. The quests which matter for progression (the class changes) are performed directly, and trading would be an abuse surface. diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index f500cee9f..937af576f 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -1377,13 +1377,38 @@ private bool TryGetHomeEscapeGate([MaybeNullWhen(false)] out ExitGate gate, [May return true; } + /// + /// The lowest monster level which still earns this bot anything: a master-class character at the + /// game's maximum level gains master experience only from monsters of at least + /// GameConfiguration.MinimumMonsterLevelForMasterExperience (the experience path grants it + /// nothing at all below that) and no regular experience either, because it is at the maximum level + /// already. Hunting below that floor is therefore + /// not the safe choice for a mastered bot, it is a dead end - so the map and hunting ground choice + /// look above the floor first. 0 for every other bot, which hunts by safety alone. + /// + private int MasterExperienceFloor() + { + var configuration = this._player.GameContext.Configuration; + if (this._player.SelectedCharacter?.CharacterClass?.IsMasterClass != true + || (this._player.Attributes?[Stats.Level] ?? 0) < configuration.MaximumLevel) + { + return 0; + } + + return configuration.MinimumMonsterLevelForMasterExperience; + } + /// /// The level of the strongest monster on the map which this bot can safely fight (judged by the /// monster's real damage against the bot's defense and health, see ); /// 0 if the map has none. The level of the safe monsters remains the measure of a map's experience - /// value when comparing maps - it just no longer decides what is SAFE. + /// value when comparing maps - it just no longer decides what is SAFE. Monsters below + /// are not counted at all, which lets a mastered bot judge a map by + /// what it actually earns there (see ). /// - private int BestSafeLevel(GameMapDefinition mapDefinition) + /// The map to judge. + /// The lowest monster level worth counting. + private int BestSafeLevel(GameMapDefinition mapDefinition, int minimumLevel = 0) { var best = 0; foreach (var area in mapDefinition.MonsterSpawns) @@ -1394,7 +1419,7 @@ private int BestSafeLevel(GameMapDefinition mapDefinition) } var level = GetMonsterLevel(area.MonsterDefinition!); - if (level > best && CombatHandler.IsSafeTarget(this._player, area.MonsterDefinition!)) + if (level >= minimumLevel && level > best && CombatHandler.IsSafeTarget(this._player, area.MonsterDefinition!)) { best = level; } @@ -1423,13 +1448,28 @@ private bool IsPermanentMonsterSpawn(MonsterSpawnArea area) /// no matter how it compares to the current one. /// private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) + { + // A mastered bot earns nothing below the master-experience floor, so it first looks for a map + // which pays at all. Only when no map within its reach offers monsters above the floor that it + // can safely fight does it fall back to hunting by safety alone: standing on a map it cannot + // survive would not earn it master experience either, and its gear still improves meanwhile. + var floor = this.MasterExperienceFloor(); + return (floor > 0 && this.TryPickBetterMapCore(escape, floor, out gate, out mapDefinition, out monsterLevel)) + || this.TryPickBetterMapCore(escape, 0, out gate, out mapDefinition, out monsterLevel); + } + + /// + /// Picks the best map (see ), counting only monsters of at least + /// . + /// + private bool TryPickBetterMapCore(bool escape, int minimumMonsterLevel, out ExitGate gate, out GameMapDefinition mapDefinition, out int monsterLevel) { gate = default!; mapDefinition = default!; monsterLevel = 0; var current = this._player.CurrentMap?.Definition; - var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current)) + WarpImprovementMargin; + var threshold = escape ? 1 : (current is null ? 0 : this.BestSafeLevel(current, minimumMonsterLevel)) + WarpImprovementMargin; foreach (var candidate in this._player.GameContext.Configuration.Maps) { @@ -1438,7 +1478,7 @@ private bool TryPickBetterMap(bool escape, out ExitGate gate, out GameMapDefinit continue; } - var best = this.BestSafeLevel(candidate); + var best = this.BestSafeLevel(candidate, minimumMonsterLevel); if (best < threshold || candidate.TryGetRequirementError(this._player, out _)) { continue; @@ -1480,8 +1520,21 @@ private bool TryPickHuntingGround(GameMap map, out Point ground, out int groundL // Prefer the strongest monsters the bot can safely handle (best experience); if none on this map // pass the safety check, fall back to the weakest available (the bot should warp away anyway). var safe = candidates.Where(x => CombatHandler.IsSafeTarget(this._player, x.Area.MonsterDefinition!)).ToList(); + + // A mastered bot only earns master experience above the floor (see MasterExperienceFloor), so as + // long as this map holds such monsters it hunts them - the weaker grounds would pay it nothing. + // Above the floor it then takes the WEAKEST monsters rather than the strongest: master experience + // hardly grows with the monster's level, so the cheapest kill above the floor is the best one. + var floor = this.MasterExperienceFloor(); + var masterGrounds = floor > 0 ? safe.Where(x => x.Level >= floor).ToList() : []; + List<(MonsterSpawnArea Area, int Level)> band; - if (safe.Count > 0) + if (masterGrounds.Count > 0) + { + var cheapest = masterGrounds.Min(x => x.Level); + band = masterGrounds.Where(x => x.Level <= cheapest + BandWidth).ToList(); + } + else if (safe.Count > 0) { var top = safe.Max(x => x.Level); band = safe.Where(x => x.Level >= top - BandWidth).ToList(); diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index bfe7cc60c..0a1cbbc45 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -47,6 +47,18 @@ public sealed class CombatHandler /// At the offline AI's attack pace this bounds a kill to roughly a minute or two. /// private const int MaxHitsToKill = 100; + + /// + /// See : 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 + /// GameConfiguration.MinimumMonsterLevelForMasterExperience, 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. + /// + private const int MasterHitBudgetFactor = 3; private const int ComboFinisherDelayTicks = 3; private const int InterSkillDelayTicks = 1; private const int MinComboSkillCount = 3; @@ -188,7 +200,23 @@ public static bool IsSafeTarget(Player player, MonsterDefinition monster) return false; } - return (attackPower - monsterDefense) * MaxHitsToKill >= monsterHealth; + return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth; + } + + /// + /// The number of net hits the bot may take to kill the monster (see ), + /// stretched by for a mastered bot fighting a monster which + /// actually pays it master experience (see ). + /// + 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; } /// From be6f6f681f026485ba5c71c99511068bebe8c5eb Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 19:16:43 +0200 Subject: [PATCH 49/60] Spend the points a bot is owed when it enters the world Level-up points were only invested on a level-up, so a bot which was given points while it was not playing - a freshly generated one, or one whose level-up handler failed - carried them around unspent until its next level-up, fighting with the strength of a much weaker character in the meantime. A bot which holds points now spends them when it enters the world. --- src/GameLogic/Bots/BotManager.cs | 2 ++ .../Bots/BotSkillProgressionPlugIn.cs | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/GameLogic/Bots/BotManager.cs b/src/GameLogic/Bots/BotManager.cs index 4b48a3c21..f9bf7d6ef 100644 --- a/src/GameLogic/Bots/BotManager.cs +++ b/src/GameLogic/Bots/BotManager.cs @@ -79,6 +79,8 @@ public async ValueTask SpawnBotAsync(IGameContext gameContext, string logi return false; } + BotSkillProgressionPlugIn.CatchUpPendingProgress(bot); + bot.Logger.LogInformation("Bot started for account '{LoginName}', character '{Character}'.", loginName, character.Name); return true; } diff --git a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs index 9c19d3737..ece66d10c 100644 --- a/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -28,6 +28,24 @@ public class BotSkillProgressionPlugIn : ICharacterLevelUpPlugIn { private static readonly IncreaseStatsAction IncreaseStatsAction = new(); + private static readonly BotSkillProgressionPlugIn CatchUp = new(); + + /// + /// Applies the progression a bot is owed but has not spent, when it enters the world. Points are + /// otherwise only invested on a level-up, so a character which was given points while it was not + /// playing - a freshly generated one, or one whose level-up handler failed - would carry them around + /// unspent until its next level-up and fight with the strength of a much weaker character in the + /// meantime. Cheap: only a bot which actually holds points is progressed. + /// + /// The bot which entered the world. + public static void CatchUpPendingProgress(Player player) + { + if (player.SelectedCharacter?.LevelUpPoints > 0) + { + CatchUp.CharacterLeveledUp(player); + } + } + /// public void CharacterLeveledUp(Player player) { From 4bad386763faff65099b5445dbcbb27715f5a35a Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 19:16:49 +0200 Subject: [PATCH 50/60] Keep master points off the bonuses which only work in PvP A bot spends its life hunting monsters, and it may not even attack a player unless it is attacked first - so an attack or defense rate against players does nothing for it. Those bonuses now get filled last, like the bonuses of a weapon type the bot does not carry. --- src/GameLogic/Bots/BotMasterHandler.cs | 17 ++++++++++++++-- .../Offline/BotMasterHandlerTests.cs | 20 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/GameLogic/Bots/BotMasterHandler.cs b/src/GameLogic/Bots/BotMasterHandler.cs index 72daa4209..0da3d5f7c 100644 --- a/src/GameLogic/Bots/BotMasterHandler.cs +++ b/src/GameLogic/Bots/BotMasterHandler.cs @@ -250,19 +250,32 @@ private static bool CanLearn(Player player, Skill skill, int availablePoints) /// A pick is "useful" when it demonstrably does something for this bot: a passive boosting a stat, /// or a strengthener/mastery of a skill the bot actually has in its list. A passive tied to a WEAPON /// type the bot does not fight with is not (a bow strengthener does nothing for a bot swinging a - /// sword) - those get filled last, after everything which actually helps. + /// sword), and neither is one which only applies against other PLAYERS: a bot spends its life + /// hunting monsters, and it may not even attack a player unless attacked first (see + /// ). Both get filled last, after everything which actually helps. /// private static bool IsUsefulPick(Player player, Skill skill, ISkillList skillList) { var definition = skill.MasterDefinition!; if (definition.TargetAttribute is { } target) { - return WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup); + return !IsPvpOnlyBonus(target) + && (WeaponGroupOfBonus(target) is not { } weaponGroup || CarriesWeaponOfGroup(player, weaponGroup)); } return definition.ReplacedSkill is { } replaced && skillList.ContainsSkill((ushort)replaced.Number); } + /// + /// Whether the master bonus only ever applies in a fight against another player, which is not what a + /// bot's points are for. + /// + /// The bonus attribute. + private static bool IsPvpOnlyBonus(AttributeDefinition attribute) + { + return attribute == Stats.AttackRatePvp || attribute == Stats.DefenseRatePvp; + } + /// /// The item group of the weapon a master bonus attribute belongs to, or null when the bonus /// helps regardless of the weapon (health, defense, an attack rate, ...). The item groups come from diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs index 30f97ca5e..47c7bfb92 100644 --- a/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs +++ b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs @@ -186,6 +186,26 @@ public async ValueTask PrefersUsefulSkillAsync() Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(passiveSkill)); } + /// + /// A bonus which only applies against other players does nothing for a bot: it spends its life + /// hunting monsters. It is picked last, after a passive which helps it there. + /// + [Test] + public async ValueTask PrefersPvmBonusOverPvpBonusAsync() + { + var player = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + var characterClass = player.SelectedCharacter!.CharacterClass!; + var pvpSkill = this.CreateMasterSkill(1, rank: 1, characterClass); + pvpSkill.MasterDefinition!.TargetAttribute = Stats.DefenseRatePvp; + var pvmSkill = this.CreateMasterSkill(2, rank: 1, characterClass); + pvmSkill.MasterDefinition!.TargetAttribute = Stats.MaximumHealth; + player.GameContext.Configuration.Skills.Add(pvpSkill); + player.GameContext.Configuration.Skills.Add(pvmSkill); + player.SelectedCharacter.MasterLevelUpPoints = 1; + + Assert.That(BotMasterHandler.PickNextMasterSkill(player), Is.SameAs(pvmSkill)); + } + /// /// With everything learned at its maximum nothing is picked - the loop stops. /// From a81258b2f45b5736a50243c9a70c4263c95c5b72 Mon Sep 17 00:00:00 2001 From: nolt Date: Tue, 14 Jul 2026 19:30:19 +0200 Subject: [PATCH 51/60] Fix the markdown of the mastered-bot hunting paragraph A wrapped line started with a hyphen, which markdownlint reads as a list item without the surrounding blank lines. --- docs/Bots.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/Bots.md b/docs/Bots.md index 0a1feaef2..798398c1a 100644 --- a/docs/Bots.md +++ b/docs/Bots.md @@ -116,15 +116,15 @@ the bots do it too. A mastered bot changes what it hunts. Master experience is only granted for monsters of at least `Minimum monster level for master experience` (95 in the -default configuration), and a character at the maximum level earns nothing else -- so below that line a kill pays a mastered bot nothing at all. It therefore -looks for maps which hold such monsters, and takes the WEAKEST ones above the -line rather than the strongest: master experience hardly grows with the -monster's level, so the cheapest kill above it is the best one. Those monsters -carry 40.000+ health, well beyond the hit budget a bot's usual gear affords, so -the budget is stretched for them - a slow fight it survives beats a quick one -worth nothing. What is not stretched is its survivability: a monster whose hits -the bot cannot take is refused, mastered or not. +default configuration), and a character at the maximum level earns nothing +else — so below that line a kill pays a mastered bot nothing at all. It +therefore looks for maps which hold such monsters, and takes the weakest ones +above the line rather than the strongest: master experience hardly grows with +the monster's level, so the cheapest kill above it is the best one. Those +monsters carry 40.000+ health, well beyond the hit budget a bot's usual gear +affords, so the budget is stretched for them — a slow fight it survives beats a +quick one worth nothing. What is not stretched is its survivability: a monster +whose hits the bot cannot take is refused, mastered or not. ### Items and money From 93cd0bd344a93908a0b87df962a53e1169fd33bd Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 15 Jul 2026 07:38:20 +0200 Subject: [PATCH 52/60] Hash bot passwords with a minimal BCrypt work factor A bot's password is a random Guid which is discarded at once and never used to log in - a bot is a connection-less OfflinePlayer, so no client authenticates against it. The default work factor made HashPassword the dominant cost of generating a large population (minutes for a thousand accounts). A minimal factor is safe here (a 128-bit random secret is infeasible to brute-force at any factor) and still stores a valid BCrypt hash; real accounts keep the default. Raised by the automated review of PR 820. --- src/GameLogic/Bots/BotGenerator.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Bots/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs index 86a87d3b6..13e54038b 100644 --- a/src/GameLogic/Bots/BotGenerator.cs +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -28,6 +28,17 @@ namespace MUnique.OpenMU.GameLogic.Bots; internal sealed class BotGenerator { private const string LoginPrefix = "bot"; + + /// + /// BCrypt work factor for a bot account's password. The password is a random + /// which is discarded immediately and never used to log in - a bot is a connection-less + /// OfflinePlayer, so no client ever authenticates against it. A minimal factor is therefore + /// safe (a 128-bit random secret is infeasible to brute-force regardless of the factor) and keeps + /// generating a large population from becoming a multi-minute BCrypt bottleneck, while still storing + /// a valid BCrypt hash. The default factor is kept for real accounts. + /// + private const int BotPasswordWorkFactor = 4; + private const int MinLevel = 10; /// @@ -147,7 +158,7 @@ public async ValueTask EnsureBotsAsync(int numberOfAccounts, int characters var account = context.CreateNew(); account.LoginName = loginName; - account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString()); + account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor); account.IsBot = true; account.Vault = context.CreateNew(); From c90ae98135d5e75876e1c1cfd050c53c840a75b6 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 15 Jul 2026 07:38:20 +0200 Subject: [PATCH 53/60] Explain the deliberate local time in the presence rotation The daily activity curve models human presence, so it follows the players' wall clock (the host's local time), not UTC like the durations elsewhere in the class. Documented so the deviation does not read as an oversight; switching it to UtcNow would drift the evening peak away from the players' evening. Raised by the automated review of PR 820. --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index e865e3b1b..e92c84ba2 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -424,6 +424,11 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState } var minShare = Math.Clamp(configuration.MinOnlineSharePercent, 0, 100) / 100.0; + + // Local wall-clock time on purpose (the rest of the class uses UtcNow for durations): this curve + // models human presence - fewest in the early morning, most in the evening - so it has to follow + // the players' day, which is the host's local time, not UTC. On a UTC-configured host the two + // coincide; on a host set to the player base's zone, local time keeps the peak in their evening. var activity = ActivityByHour[DateTime.Now.Hour]; var targetOnline = (int)Math.Round(totalPopulation * (minShare + ((1.0 - minShare) * activity))); var online = state.Manager.Bots.Count; From 7e888b0c3708be31a643e36e0982de4a2d4eb553 Mon Sep 17 00:00:00 2001 From: nolt Date: Wed, 15 Jul 2026 07:38:20 +0200 Subject: [PATCH 54/60] Abort the bot's travel path wait on shutdown TravelTowardAsync queued for a shared path finder with an untokened WaitAsync, so a bot travelling while the navigator is disposed held the tick until a finder freed up. Thread the navigator's shutdown token (already captured once in Start, never touching the disposed source) down to the wait; the OperationCanceledException is expected and already handled in SafeEvaluateAsync. Raised by the automated review of PR 820. --- src/GameLogic/Bots/BotNavigator.cs | 47 +++++++++++++++++------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs index 937af576f..b640fa32d 100644 --- a/src/GameLogic/Bots/BotNavigator.cs +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -332,7 +332,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // (shopping, revenge, warping, ground picking) must not run. if (this._player.CurrentMiniGame is not null) { - await this.EvaluateInsideMiniGameAsync(map).ConfigureAwait(false); + await this.EvaluateInsideMiniGameAsync(map, cancellationToken).ConfigureAwait(false); return; } @@ -401,7 +401,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // walker, and a new destination avoids re-stalling on the same blocked path. this._lastMoveUtc = DateTime.UtcNow; this._emptyGroundSince = null; - await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); + await this.PickGroundAndTravelAsync(map, cancellationToken).ConfigureAwait(false); return; } @@ -432,7 +432,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // A shopping trip (selling junk loot, restocking potions with Zen) runs before everything else, // so it completes even for party members - the follow logic below would otherwise pull them // back to the leader mid-trade. - if (await this.TryShoppingAsync(map, inSafezone).ConfigureAwait(false)) + if (await this.TryShoppingAsync(map, inSafezone, cancellationToken).ConfigureAwait(false)) { return; } @@ -442,7 +442,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // the place of its death. A leader on a revenge march still drags its followers along - the // posse a wronged player would bring. Once the revenge is spent or times out, the bot falls // back into the routine (and a follower rejoins its group). - if (await this.TryRevengeMarchAsync(map).ConfigureAwait(false)) + if (await this.TryRevengeMarchAsync(map, cancellationToken).ConfigureAwait(false)) { return; } @@ -455,7 +455,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) var leaderToFollow = this.GetPartyLeaderToFollow(); if (leaderToFollow is not null) { - if (await this.TryFollowLeaderAsync(map, leaderToFollow).ConfigureAwait(false)) + if (await this.TryFollowLeaderAsync(map, leaderToFollow, cancellationToken).ConfigureAwait(false)) { return; } @@ -493,7 +493,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) { this._destination = monsterGround; this._hasDestination = true; - if (await this.TravelTowardAsync(map, monsterGround).ConfigureAwait(false)) + if (await this.TravelTowardAsync(map, monsterGround, cancellationToken).ConfigureAwait(false)) { this._emptyGroundSince = null; return; @@ -509,7 +509,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) // ground in this same tick instead of standing still. if (this._hasDestination && this._player.GetDistanceTo(this._destination) > HuntingRange) { - if (!await this.TravelTowardAsync(map, this._destination).ConfigureAwait(false)) + if (!await this.TravelTowardAsync(map, this._destination, cancellationToken).ConfigureAwait(false)) { // Impossible route: drop the destination and wait out the empty-ground grace before // picking another one. Re-picking immediately turned a pocket of unreachable picks @@ -541,7 +541,7 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) return; } - await this.PickGroundAndTravelAsync(map).ConfigureAwait(false); + await this.PickGroundAndTravelAsync(map, cancellationToken).ConfigureAwait(false); } /// @@ -550,13 +550,13 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken) /// walks up to it and trades - selling junk loot and restocking potions with Zen. /// /// True, if the shopping trip consumed this tick. - private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone) + private async ValueTask TryShoppingAsync(GameMap map, bool inSafezone, CancellationToken cancellationToken) { if (this._shoppingTarget is { } target) { if (this._player.GetDistanceTo(target) > MerchantTalkRange) { - if (!await this.TravelTowardAsync(map, target).ConfigureAwait(false)) + if (!await this.TravelTowardAsync(map, target, cancellationToken).ConfigureAwait(false)) { // No way to the merchant from here - give up this trip and don't retry every // check interval (an unreachable merchant stays unreachable for a while; a bot @@ -654,8 +654,9 @@ private async ValueTask TradeAndUpgradeAsync(GameMap map, Point merchantPosition /// next to the bot); a bot that stopped to farm would never arrive within the revenge time. /// /// The current map. + /// The token which aborts the travel wait on shutdown. /// True, if the revenge march consumed this tick. - private async ValueTask TryRevengeMarchAsync(GameMap map) + private async ValueTask TryRevengeMarchAsync(GameMap map, CancellationToken cancellationToken) { if (!this._player.TryGetRevengeDestination(map.Definition, out var deathSite)) { @@ -682,7 +683,7 @@ private async ValueTask TryRevengeMarchAsync(GameMap map) this._destination = deathSite; this._hasDestination = true; - if (!await this.TravelTowardAsync(map, deathSite).ConfigureAwait(false)) + if (!await this.TravelTowardAsync(map, deathSite, cancellationToken).ConfigureAwait(false)) { // No walkable route back (e.g. the respawn gate is across a river) - drop the attempt // instead of re-issuing an impossible walk every tick. @@ -701,7 +702,7 @@ private async ValueTask TryRevengeMarchAsync(GameMap map) /// safezone like a player (which removes it from the event), and the event warps the remaining /// participants out when it ends. /// - private async ValueTask EvaluateInsideMiniGameAsync(GameMap map) + private async ValueTask EvaluateInsideMiniGameAsync(GameMap map, CancellationToken cancellationToken) { // Keep the drinking supplies topped up - an event without potions ends quickly. this._player.PendingBotActions.Enqueue(() => this.EnsurePotionsAsync()); @@ -723,7 +724,7 @@ private async ValueTask EvaluateInsideMiniGameAsync(GameMap map) && ReferenceEquals(leader.CurrentMap, map) && this._player.GetDistanceTo(leader.Position) > FollowDistance) { - await this.TravelTowardAsync(map, leader.Position).ConfigureAwait(false); + await this.TravelTowardAsync(map, leader.Position, cancellationToken).ConfigureAwait(false); return; } @@ -732,7 +733,7 @@ private async ValueTask EvaluateInsideMiniGameAsync(GameMap map) if (!map.GetAttackablesInRange(this._player.Position, TravelStopRange).Any(this.IsEventTarget) && this.TryFindNearestEventTargetGround(map, out var ground)) { - await this.TravelTowardAsync(map, ground).ConfigureAwait(false); + await this.TravelTowardAsync(map, ground, cancellationToken).ConfigureAwait(false); } } @@ -808,7 +809,7 @@ private bool TryFindNearestEventTargetGround(GameMap map, out Point ground) /// and walks towards the leader when it moved out of the follow range. /// /// True, if following consumed this tick; false, if the bot is close enough and should hunt normally. - private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) + private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader, CancellationToken cancellationToken) { if (!ReferenceEquals(leader.CurrentMap, map)) { @@ -869,7 +870,7 @@ private async ValueTask TryFollowLeaderAsync(GameMap map, Player leader) { this._destination = leader.Position; this._hasDestination = true; - await this.TravelTowardAsync(map, leader.Position).ConfigureAwait(false); + await this.TravelTowardAsync(map, leader.Position, cancellationToken).ConfigureAwait(false); return true; } @@ -1020,7 +1021,7 @@ private async ValueTask TryWarpToBetterMapAsync() /// issued; the remainder is recomputed from the new position on the next tick. The safe zone is allowed /// in the path so the bot can leave town. /// - private async ValueTask TravelTowardAsync(GameMap map, Point destination) + private async ValueTask TravelTowardAsync(GameMap map, Point destination, CancellationToken cancellationToken) { var position = this._player.Position; @@ -1039,7 +1040,11 @@ private async ValueTask TravelTowardAsync(GameMap map, Point destination) this._travelPath = null; IList? path; - await TravelPathFinderPool.WaitAsync().ConfigureAwait(false); + + // Observe the navigator's shutdown token while queuing for a shared path finder: on dispose the + // wait is abandoned at once (the OperationCanceledException is expected and handled in + // SafeEvaluateAsync) instead of holding the tick until a finder frees up. + await TravelPathFinderPool.WaitAsync(cancellationToken).ConfigureAwait(false); PathFinder? finder = null; try { @@ -1099,7 +1104,7 @@ private async ValueTask WalkCachedStepsAsync(Point position) /// Picks a hunting ground and starts walking to it. If the chosen ground turns out to be unreachable the /// destination is dropped, so the next call (next tick) simply picks another one. /// - private async ValueTask PickGroundAndTravelAsync(GameMap map) + private async ValueTask PickGroundAndTravelAsync(GameMap map, CancellationToken cancellationToken) { if (!this.TryPickHuntingGround(map, out var ground, out var groundLevel)) { @@ -1117,7 +1122,7 @@ private async ValueTask PickGroundAndTravelAsync(GameMap map) ground, groundLevel); - if (!await this.TravelTowardAsync(map, ground).ConfigureAwait(false)) + if (!await this.TravelTowardAsync(map, ground, cancellationToken).ConfigureAwait(false)) { this._hasDestination = false; } From 2f3e0b0168a1ea10267d6dabcbce0eaec0fdf561 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:15 +0200 Subject: [PATCH 55/60] Remove a stray duplicate summary tag in the bot feature plugin A copy of the ServerState summary was left dangling above PersistConfigurationAsync, which raises CS1571 (duplicate XML comment tag) and can fail the build under doc generation or StyleCop. Delete the stray block; the method keeps its own summary. --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index e92c84ba2..1cbcd55f0 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -465,10 +465,6 @@ private async ValueTask RotatePresenceAsync(GameContext gameContext, ServerState } } - /// - /// The bot feature's state of ONE game server: its own population share, its own bots, and its own - /// startup and maintenance schedule (see ). - /// /// /// Persists the current configuration back to its row, so a /// programmatic change (e.g. clearing the reset flag) survives a restart. From 3ffd86aeeead2f051d8dd1c6182f9fef6d4fa816 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:24 +0200 Subject: [PATCH 56/60] Name the bot startup phases with an enum instead of raw 0/1/2 The per-server startup state is a genuine three-state machine that previously needed a doc comment to explain that 0 = not started, 1 = in progress, 2 = done. Introduce a StartupPhase enum for it. The field stays an int at the CLR level because Interlocked has no overload for arbitrary enum types, so the phase constants are cast at the call sites; _maintenanceRunning stays the 0/1 interlocked flag it is. --- src/GameLogic/Bots/BotFeaturePlugIn.cs | 34 +++++++++++++++++++------- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/src/GameLogic/Bots/BotFeaturePlugIn.cs b/src/GameLogic/Bots/BotFeaturePlugIn.cs index 1cbcd55f0..2ea6afa82 100644 --- a/src/GameLogic/Bots/BotFeaturePlugIn.cs +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -50,6 +50,24 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus /// private readonly ConcurrentDictionary _states = new(); + /// + /// The phase of a game server's single startup pass. The periodic task timer fires every second + /// WITHOUT awaiting the previous invocation, so during the minutes-long generation/spawn further + /// ticks arrive concurrently - they must neither re-enter the startup nor run the maintenance + /// (e.g. the presence rotation) against a half-spawned population. + /// + private enum StartupPhase + { + /// The startup has not run yet. + NotStarted = 0, + + /// The startup (generation and spawn) is in progress. + InProgress = 1, + + /// The startup is done; the server is in maintenance mode. + Done = 2, + } + /// public BotConfiguration? Configuration { get; set; } @@ -57,14 +75,14 @@ public class BotFeaturePlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCus public async ValueTask ExecuteTaskAsync(GameContext gameContext) { var state = this._states.GetOrAdd(gameContext, _ => new ServerState()); - if (state.StartupState == 2) + if (state.StartupState == (int)StartupPhase.Done) { await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false); return; } if (DateTime.UtcNow < state.NextRunUtc - || Interlocked.CompareExchange(ref state.StartupState, 1, 0) != 0) + || Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted) { return; } @@ -73,7 +91,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) if (!configuration.Enabled) { // Not spawned - re-check on the following ticks, the feature may get enabled later. - Interlocked.Exchange(ref state.StartupState, 0); + Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.NotStarted); return; } @@ -85,7 +103,7 @@ public async ValueTask ExecuteTaskAsync(GameContext gameContext) { // Like before: the startup runs once, even when parts of it failed (the errors are // logged); the maintenance pass takes over from here. - Interlocked.Exchange(ref state.StartupState, 2); + Interlocked.Exchange(ref state.StartupState, (int)StartupPhase.Done); } } @@ -501,11 +519,9 @@ private async ValueTask PersistConfigurationAsync(GameContext gameContext, BotCo private sealed class ServerState { /// - /// 0 = startup not run yet, 1 = startup in progress, 2 = startup done (maintenance mode). - /// The periodic task timer fires every second WITHOUT awaiting the previous invocation, so - /// during the minutes-long generation/spawn further ticks arrive concurrently - they must - /// neither re-enter the startup nor run the maintenance (e.g. the presence rotation) against - /// a half-spawned population. Interlocked-updated, hence a field. + /// The of this server, as a raw : it is transitioned + /// with , which has no overload for arbitrary enum types, so the field + /// stays an and the phase constants are cast at the call sites. /// private int _startupState; From b33d80b207a14887f507dd29f7f2f5fc0bfb8608 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:29 +0200 Subject: [PATCH 57/60] Key the offline monster stats cache by monster number The static cache was keyed on the MonsterDefinition instance, so a runtime configuration reload builds new instances and orphans the old keys forever. Key it by the monster number instead - the cached values are derived purely from the monster's stats - so a reload reuses the entries rather than leaking them. --- src/GameLogic/Offline/CombatHandler.cs | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/GameLogic/Offline/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 0a1cbbc45..035cef885 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -77,9 +77,11 @@ public sealed class CombatHandler /// /// 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. + /// 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 instances) reuses the entries instead of orphaning them. /// - private static readonly ConcurrentDictionary MonsterStatsCache = new(); + private static readonly ConcurrentDictionary MonsterStatsCache = new(); private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; @@ -328,15 +330,18 @@ public async ValueTask PerformDrainLifeRecoveryAsync() private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster) { - return MonsterStatsCache.GetOrAdd(monster, 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)); - }); + 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); } /// From 520ce4d1f002e830a6115f68c44177b340bde7e2 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:33 +0200 Subject: [PATCH 58/60] Fold the bot's last aggressor into an immutable record The aggressor player and the aggression time were two plain fields written from the attack path and read from the AI tick. A DateTime write is not atomic, so a torn read was possible. Fold them into a single volatile Aggression record, matching the _revenge and _deathSiteToAvoid pattern already used for the same reason: the read is now a single reference load and cannot tear. --- src/GameLogic/Offline/OfflinePlayer.cs | 27 +++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/src/GameLogic/Offline/OfflinePlayer.cs b/src/GameLogic/Offline/OfflinePlayer.cs index 5eda749b5..72ae371ef 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -55,8 +55,13 @@ public class OfflinePlayer : Player private OfflinePlayerMuHelper? _intelligence; private Task? _intelligenceDisposeTask; - private Player? _lastAggressor; - private DateTime _lastAggressionUtc; + + /// + /// 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 read. + /// + private volatile Aggression? _aggression; /// /// The pending (not yet armed, is null) or armed revenge. @@ -115,12 +120,12 @@ internal Player? RecentAggressor { get { - if (this._lastAggressor is { } aggressor - && DateTime.UtcNow - this._lastAggressionUtc <= AggressionMemory - && aggressor.IsAlive - && !aggressor.IsAtSafezone()) + if (this._aggression is { } aggression + && DateTime.UtcNow - aggression.AtUtc <= AggressionMemory + && aggression.Aggressor.IsAlive + && !aggression.Aggressor.IsAtSafezone()) { - return aggressor; + return aggression.Aggressor; } return null; @@ -219,8 +224,7 @@ public virtual async ValueTask StopAsync() /// The player who attacked this bot. internal void RegisterAggressor(Player aggressor) { - this._lastAggressor = aggressor; - this._lastAggressionUtc = DateTime.UtcNow; + this._aggression = new Aggression(aggressor, DateTime.UtcNow); } /// @@ -466,4 +470,9 @@ private sealed record RevengeState(Player Killer, Point DeathPosition, GameMapDe /// A death site the bot avoids when picking hunting grounds, after repeated deaths there. /// private sealed record DeathSite(Point Position, GameMapDefinition Map, DateTime AvoidUntilUtc); + + /// + /// The most recent aggression against this bot: who attacked and when. + /// + private sealed record Aggression(Player Aggressor, DateTime AtUtc); } From c139cb48d39aa18a896d30d94cf82bef02fda739 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:40 +0200 Subject: [PATCH 59/60] Refresh the stale proof-of-concept wording in the bot configuration The remarks on NumberOfAccounts and ProofOfConceptAccounts read as if the generation step were still a future phase - it is implemented. Drop the obsolete note and document ProofOfConceptAccounts for what it is now: an optional extra hook to animate existing accounts alongside the generated, capacity-limited population. --- src/GameLogic/Bots/BotConfiguration.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/GameLogic/Bots/BotConfiguration.cs b/src/GameLogic/Bots/BotConfiguration.cs index 68efd2834..66279a149 100644 --- a/src/GameLogic/Bots/BotConfiguration.cs +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -48,10 +48,8 @@ public class BotConfiguration /// /// Gets or sets the number of bot accounts. Together with - /// this defines the bot population, e.g. 10 accounts × 5 characters = 50 bot characters. + /// this defines the generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters. /// - /// Used by the generation step (next phase). The proof of concept animates the - /// accounts listed in instead. [Display(Name = "Number of accounts", Description = "How many bot accounts to maintain.")] [Range(0, 1000)] public int NumberOfAccounts { get; set; } = 10; @@ -87,10 +85,12 @@ public class BotConfiguration /// /// Gets or sets a comma separated list of login names of existing accounts to animate as bots. - /// This is the proof-of-concept entry point: every listed account gets a bot driving its - /// first character. A later phase replaces this with generated, persistent bot accounts. + /// This is an optional extra hook alongside the generated population (see + /// ): every listed account gets a bot driving its first character. + /// These accounts are animated as-is and are not part of the partitioned, capacity-limited + /// population, so leave it empty unless you specifically want to drive existing accounts. /// - [Display(Name = "Proof of concept accounts", Description = "Comma separated login names of existing accounts to animate as bots (PoC).")] + [Display(Name = "Extra accounts to animate", Description = "Comma separated login names of existing accounts to animate as bots, in addition to the generated population.")] public string ProofOfConceptAccounts { get; set; } = string.Empty; /// From 1686b529d1702589cc601429c1528c508a5f33b5 Mon Sep 17 00:00:00 2001 From: nolt Date: Thu, 16 Jul 2026 07:42:40 +0200 Subject: [PATCH 60/60] Explain the one-shot fault-restart latch in the bot player The consecutive-failure guard fires on '== threshold', not '>=', on purpose: it arms the restart exactly once at the tick that crosses the threshold. Spell that out so the '==' no longer reads as a fencepost bug - further failures keep incrementing the counter without re-firing, and the maintenance pass consumes the flag and resets it. --- src/GameLogic/Bots/BotPlayer.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs index 9cf8d114f..194071c03 100644 --- a/src/GameLogic/Bots/BotPlayer.cs +++ b/src/GameLogic/Bots/BotPlayer.cs @@ -77,7 +77,10 @@ internal override void OnAiTickFailed() { if (Interlocked.Increment(ref this._consecutiveTickFailures) == ConsecutiveFailuresUntilRestart) { - // Only when the counter HITS the threshold, so the log gets one line, not one per tick. + // Deliberately '==', not '>=': this arms the restart exactly once, at the tick that crosses + // the threshold, so the log gets one line and the flag is raised once. Further failures keep + // incrementing the counter (which stays above the threshold) but don't re-fire; the + // maintenance pass consumes AwaitsFaultRestart and the restart resets the counter to 0. this.Logger.LogWarning( "Bot '{Name}' failed {Count} AI ticks in a row and gets restarted to heal it.", this.Name,