Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions docs-website/docs/server-features/bots.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,19 @@ range are three tiles at any level. Skills the game only activates during a
castle siege are left out, and so are a pet's skills unless the pet is actually
equipped: Plasma Storm draws its damage from the Fenrir, but the attribute behind
it is derived from the character's own stats, so nothing but the pet slot tells a
mounted character from one riding nothing. 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.
mounted character from one riding nothing. Mount-bound skills stay out entirely
instead: bots neither learn nor use them (even if a looted pet ends up in the
pet slot). Skills are learned like a human
player learns them: a freshly generated bot starts with the skills its start
level and stats entitle it to (a fresh level-1 character almost none, a veteran
a plausible kit up to its level), and from then on it loots skill orbs and
scrolls from the ground and consumes them - never granted magically on level-up.
A scroll which does not drop yet where the bot hunts stays unknown until the bot
gets there, and skill-orb pickup follows the same upgrade-items toggle as gear.
Once learned, the class buffs are kept up on their own. Mount-bound previously
learned skills go quiet on their own (they are never selected), but scroll skills
a character learned too early keep working - regenerating the population with
`Reset bots` is the way to a clean slate.

A skill the character cannot currently cast is passed over, in the attack
rotation and in the buffs alike. That is not the same as not having learned it: a
Expand Down
186 changes: 22 additions & 164 deletions src/GameLogic/Bots/BotGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ 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.GameLogic.Resets;
Expand Down Expand Up @@ -44,24 +43,6 @@ internal sealed class BotGenerator
/// <summary>Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.</summary>
private const int BotInventoryExtensions = 4;

/// <summary>The highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).</summary>
private const byte MaxMeleeGroup = 3;

/// <summary>Item group of bows (need ammunition).</summary>
private const byte BowGroup = 4;

/// <summary>Item group of staves/sticks (casters).</summary>
private const byte StaffGroup = 5;

/// <summary>Item group of body armor; its item number identifies the armor set.</summary>
private const byte ArmorGroup = 8;

/// <summary>
/// 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.
/// </summary>
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();
Expand Down Expand Up @@ -172,7 +153,7 @@ public async ValueTask<int> EnsureBotsAsync(int numberOfAccounts, int characters
var level = profile.GetStartLevel(minLevel, maxLevel);
var seededResets = profile.GetSeededResets(maxSeededResets);
var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false);
this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, profile.StarterItemLevel, resetConfiguration);
this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, profile.StarterItemLevel, profile.EquipStarterArmor, resetConfiguration);
}

// Save per account so a single failure does not roll back already generated accounts,
Expand Down Expand Up @@ -459,7 +440,7 @@ private async ValueTask<BotAccountDeleteOutcome> TryDeleteBotAccountAsync(string
}
}

private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, byte starterItemLevel, ResetConfiguration? resetConfiguration)
private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, byte starterItemLevel, bool equipStarterArmor, 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
Expand Down Expand Up @@ -522,7 +503,18 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam

character.Inventory = context.CreateNew<ItemStorage>();
character.Inventory.Money = StartMoney;
this.EquipStarterGear(context, character, starterItemLevel);

// A fresh character starts like a regular player's new character - weapon only, no armor -
// and loots its first set like everyone else. Veterans keep the basic set, without which
// they could not survive the maps their start level puts them on.
var starterGear = new BotStarterGearEquipper(context, this._gameContext.Configuration, character, starterItemLevel);
starterGear.EquipWeapon();
if (equipStarterArmor)
{
starterGear.EquipArmorSet();
}

starterGear.AddPotions();

account.Characters.Add(character);
}
Expand All @@ -532,7 +524,11 @@ private void CreateCharacter(IPlayerContext context, Account account, string nam
/// 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.
/// was just given - exactly the requirements a human player has to meet for the same skill. Item-bound
/// skills follow the backfill rules (see <see cref="BotProgression.MayBackfillSkill"/>): orb/scroll
/// skills only when their granting item is obtainable, so a bot cannot learn a scroll before the
/// monster level where it starts to drop - and never when the skill comes from worn equipment or a
/// pet, which the server grants temporarily on equip instead.
/// </summary>
private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level)
{
Expand All @@ -549,12 +545,13 @@ private void LearnClassSkills(IPlayerContext context, Character character, Chara
}

var learnedNumbers = new HashSet<short>(character.LearnedSkills.Select(s => s.Skill!.Number));
var itemGrantedSkillNumbers = BotProgression.GetItemGrantedSkillNumbers(this._gameContext.Configuration);
var grantingItems = BotProgression.GetGrantingItems(this._gameContext.Configuration);
foreach (var skill in this._gameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill, itemGrantedSkillNumbers)
if (!BotProgression.MayBotOwnSkill(skill)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| !BotProgression.MeetsRequirements(skill, GetValue)
|| !BotProgression.MayBackfillSkill(skill, grantingItems, characterClass, level, GetValue)
|| !learnedNumbers.Add(skill.Number))
{
continue;
Expand All @@ -566,143 +563,4 @@ private void LearnClassSkills(IPlayerContext context, Character character, Chara
character.LearnedSkills.Add(entry);
}
}

/// <summary>
/// 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.
/// </summary>
/// <param name="context">The persistence context.</param>
/// <param name="character">The character to equip.</param>
/// <param name="starterItemLevel">The upgrade level of the starter items (level 0 for fresh characters).</param>
private void EquipStarterGear(IPlayerContext context, Character character, byte starterItemLevel)
{
var inventory = character.Inventory!;
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).
// The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the
// later upgrades use), so an energy-specked 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, (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))
.MinBy(d => d.DropLevel)
?? this._gameContext.Configuration.Items
.Where(d => d.Group <= StaffGroup && !d.IsAmmunition && 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, starterItemLevel);
this.AddAmmunition(context, inventory);
}
else
{
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon, starterItemLevel);
}
}

// 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, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set, starterItemLevel);
break;
}

this.AddPotions(context, inventory);
}

private void AddPotions(IPlayerContext context, ItemStorage inventory)
{
// 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;
}

var item = context.CreateNew<Item>();
item.Definition = potion;

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

private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number, byte starterItemLevel)
{
var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number);
if (definition is null || !definition.QualifiedCharacters.Contains(characterClass))
{
return;
}

this.AddEquippedItem(context, inventory, characterClass, slot, definition, starterItemLevel);
}

private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition, byte starterItemLevel)
{
if (!definition.QualifiedCharacters.Contains(characterClass))
{
return;
}

var item = context.CreateNew<Item>();
item.Definition = definition;
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>();
item.Definition = arrows;
item.Durability = 255;
item.ItemSlot = InventoryConstants.LeftHandSlot;
inventory.Items.Add(item);
}
}
5 changes: 5 additions & 0 deletions src/GameLogic/Bots/BotNavigator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,11 @@ private async ValueTask EvaluateAsync(CancellationToken cancellationToken)
this._nextEquipCheckUtc = DateTime.UtcNow + EquipCheckInterval;
this._player.PendingBotActions.Enqueue(() => BotEquipmentHandler.TryEquipUpgradesAsync(this._player));

// Looted skill orbs and scrolls are consumed into new skills like a human would (see
// BotSkillHandler). Queued for the same reason: learning mutates the skill list the combat
// handler may be enumerating on its own timer.
this._player.PendingBotActions.Enqueue(() => BotSkillHandler.TryLearnSkillsAsync(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 mount item power-ups.
Expand Down
Loading
Loading