diff --git a/docs/Bots.md b/docs/Bots.md new file mode 100644 index 000000000..798398c1a --- /dev/null +++ b/docs/Bots.md @@ -0,0 +1,257 @@ +# 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. + +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 +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 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. +- **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 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..66279a149 --- /dev/null +++ b/src/GameLogic/Bots/BotConfiguration.cs @@ -0,0 +1,122 @@ +// +// 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 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 generated bot population, e.g. 10 accounts × 5 characters = 50 bot characters. + /// + [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 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: + /// 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 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 = "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; + + /// + /// 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); + + /// + /// 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. + /// + /// The list of login names. + /// 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) + .ToList(); +} diff --git a/src/GameLogic/Bots/BotEquipmentHandler.cs b/src/GameLogic/Bots/BotEquipmentHandler.cs new file mode 100644 index 000000000..9f6bc91e9 --- /dev/null +++ b/src/GameLogic/Bots/BotEquipmentHandler.cs @@ -0,0 +1,283 @@ +// +// 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; 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(); + + /// + /// 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) + { + return TryPlanSwap(player, item) is not null; + } + + /// + /// 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) + { + 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 (TryPlanSwap(player, item) is not { } plan) + { + continue; + } + + 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; + } + + var equipSlot = removed.ItemSlot; + await MoveAction.MoveItemAsync(player, equipSlot, Storages.Inventory, freeSlot, Storages.Inventory).ConfigureAwait(false); + if (inventory.GetItem(equipSlot) is not null) + { + player.Logger.LogDebug("Bot '{Name}' could not take off '{Item}' for a swap.", player.Name, removed); + await RollbackAsync(player, undo).ConfigureAwait(false); + return false; + } + + undo.Add((removed, equipSlot)); + } + + 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; + } + + 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; + } + + var removed = new List(2); + if (equipped is not null) + { + removed.Add(equipped); + } + + 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) + { + continue; + } + + removed.Add(blocking); + } + + var replacedScore = removed.Sum(Score); + if (removed.Count > 0 && replacedScore + UpgradeScoreMargin > candidateScore) + { + continue; + } + + // 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) + { + best = new EquipSwap(slot, removed); + bestReplacedScore = replacedScore; + } + } + + 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 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(Player player, ItemDefinition definition, CharacterClass characterClass) + { + 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); + } + + /// + /// 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; + } + + /// + /// 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 new file mode 100644 index 000000000..2ea6afa82 --- /dev/null +++ b/src/GameLogic/Bots/BotFeaturePlugIn.cs @@ -0,0 +1,576 @@ +// +// 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 System.Runtime.InteropServices; +using System.Threading; +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 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, + ]; + + /// + /// 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 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; } + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + var state = this._states.GetOrAdd(gameContext, _ => new ServerState()); + if (state.StartupState == (int)StartupPhase.Done) + { + await this.RunMaintenanceAsync(gameContext, state).ConfigureAwait(false); + return; + } + + if (DateTime.UtcNow < state.NextRunUtc + || Interlocked.CompareExchange(ref state.StartupState, (int)StartupPhase.InProgress, (int)StartupPhase.NotStarted) != (int)StartupPhase.NotStarted) + { + return; + } + + 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 state.StartupState, (int)StartupPhase.NotStarted); + return; + } + + try + { + 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 state.StartupState, (int)StartupPhase.Done); + } + } + + 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 && partition.IsGenerator) + { + 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."); + } + } + + if (partition.IsGenerator) + { + try + { + // 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."); + } + } + + var charactersPerAccount = Math.Clamp( + Math.Min(configuration.MaxCharactersPerAccount, gameContext.Configuration.MaximumCharactersPerAccount), + 1, + BotConfiguration.MaxCharactersPerAccountLimit); + + var started = 0; + var total = 0; + for (var i = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++) + { + var loginName = BotGenerator.GetLoginName(i); + for (byte slot = 0; slot < charactersPerAccount; slot++) + { + total++; + try + { + 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) + { + 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. They are not part of the partitioned population, so only one server animates them. + if (partition.IsGenerator) + { + foreach (var loginName in configuration.ParseProofOfConceptAccounts()) + { + try + { + 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); + } + } + } + + logger.LogInformation("Bot feature started {Started} of {Total} bots.", started, total); + + try + { + await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to form bot parties."); + } + } + + /// + public void ForceStart() + { + foreach (var state in this._states.Values) + { + state.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 + /// 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, ServerState state) + { + if (DateTime.UtcNow < state.NextMaintenanceUtc) + { + return; + } + + // 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 state.MaintenanceRunning, 1, 0) != 0) + { + return; + } + + var logger = gameContext.LoggerFactory.CreateLogger(this.GetType().Name); + try + { + state.NextMaintenanceUtc = DateTime.UtcNow + MaintenanceInterval; + + var configuration = this.Configuration; + if (configuration?.Enabled != true) + { + return; + } + + 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) + { + await this.RotatePresenceAsync(gameContext, state, configuration, logger).ConfigureAwait(false); + } + + if (DateTime.UtcNow >= state.NextPartyReformUtc) + { + state.NextPartyReformUtc = DateTime.UtcNow + PartyReformInterval; + await state.Manager.FormPartiesAsync(gameContext).ConfigureAwait(false); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Bot maintenance failed."); + } + finally + { + Interlocked.Exchange(ref state.MaintenanceRunning, 0); + } + } + + /// + /// 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). + /// 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, ServerState state, ILogger logger) + { + 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. + if (BotPartyHandler.HasHumanCompanion(bot) + || bot.PlayerState.CurrentState != PlayerState.EnteredWorld + || !bot.IsAlive) + { + continue; + } + + if (bot.AwaitsMasterRestart) + { + await this.RestartEvolvedBotAsync(gameContext, state, bot, logger).ConfigureAwait(false); + continue; + } + + if (BotMasterHandler.IsMasterEvolutionDue(bot)) + { + // 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 () => + { + if (await BotMasterHandler.TryEvolveAsync(bot).ConfigureAwait(false)) + { + bot.AwaitsMasterRestart = true; + } + }); + } + } + } + + /// + /// 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, ServerState state, 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 state.Manager.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); + state.PendingRespawns.Enqueue((loginName, slot)); + } + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to restart bot '{Name}' after its master evolution.", bot.Name); + } + } + + /// + /// Retries bringing back bots whose respawn after the master evolution failed. + /// + private async ValueTask RespawnPendingAsync(GameContext gameContext, ServerState state) + { + var count = state.PendingRespawns.Count; + for (var i = 0; i < count && state.PendingRespawns.TryDequeue(out var entry); i++) + { + if (state.Manager.IsActive(entry.Login, entry.Slot)) + { + continue; + } + + if (!await state.Manager.SpawnBotAsync(gameContext, entry.Login, entry.Slot).ConfigureAwait(false)) + { + state.PendingRespawns.Enqueue(entry); + } + } + } + + /// + /// 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 = partition.AccountCount * charactersPerAccount; + if (totalPopulation <= 0) + { + return; + } + + 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; + + 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 = partition.FirstAccount; i < partition.FirstAccount + partition.AccountCount; i++) + { + var loginName = BotGenerator.GetLoginName(i); + for (byte slot = 0; slot < charactersPerAccount; slot++) + { + if (!state.Manager.IsActive(loginName, slot)) + { + offline.Add((loginName, slot)); + } + } + } + + if (offline.SelectRandom() is { Login: not null } candidate + && 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 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); + } + } + } + + /// + /// 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."); + } + } + + /// + /// 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 + { + /// + /// 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; + + /// + /// 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/BotGenerator.cs b/src/GameLogic/Bots/BotGenerator.cs new file mode 100644 index 000000000..13e54038b --- /dev/null +++ b/src/GameLogic/Bots/BotGenerator.cs @@ -0,0 +1,606 @@ +// +// 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.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Resets; +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"; + + /// + /// 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; + + /// + /// 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. + private const byte StarterItemLevel = 6; + + /// 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; + + /// 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(); + + /// + /// 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); + + // 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; + + // 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(); + 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(), BotPasswordWorkFactor); + account.IsBot = true; + account.Vault = context.CreateNew(); + + for (byte slot = 0; slot < perAccount; slot++) + { + 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, seededResets, resetConfiguration); + } + + // 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(); + 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: what is left of this page now occupies [skip, skip + kept). + if (removed > 0) + { + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + + skip += page.Count - removed; + } + + 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; + } + + /// + /// 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 + /// 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, bool resetMeta) + { + var points = character.LevelUpPoints; + if (points <= 0) + { + return; + } + + 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) + { + attribute.Value += amount; + character.LevelUpPoints -= amount; + } + } + } + + /// + /// 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. A character with a seeded reset + // 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) + { + characterClass = evolvedClass; + } + + 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; + 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 = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration); + character.InventoryExtensions = BotInventoryExtensions; + 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. + 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; + this.EquipStarterGear(context, character, resetConfiguration is not null); + + account.Characters.Add(character); + } + + /// + /// 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) + { + float? GetValue(AttributeDefinition attribute) + { + if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat) + { + return null; + } + + return baseStat == Stats.Level + ? level + : character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value; + } + + 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)) + { + continue; + } + + var entry = context.CreateNew(); + entry.Skill = skill; + entry.Level = 0; + character.LearnedSkills.Add(entry); + } + } + + /// + /// 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, bool resetMeta) + { + 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-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)) + .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); + 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.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.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) + { + 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); + } + + 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 = 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/BotJewelHandler.cs b/src/GameLogic/Bots/BotJewelHandler.cs new file mode 100644 index 000000000..bbca39986 --- /dev/null +++ b/src/GameLogic/Bots/BotJewelHandler.cs @@ -0,0 +1,228 @@ +// +// 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, 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 +{ + /// 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; + + /// Souls are never spent below +6 - that range is Bless territory (safe and cheap). + private const byte SoulMinTargetLevel = 6; + + /// + /// 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; + + /// 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, 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 >= SoulMinTargetLevel + && i.Level <= (HasLuck(i) ? SoulMaxTargetLevelLucky : SoulMaxTargetLevelPlain)) + .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; + + // 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; + } + + 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/BotManager.cs b/src/GameLogic/Bots/BotManager.cs new file mode 100644 index 000000000..f9bf7d6ef --- /dev/null +++ b/src/GameLogic/Bots/BotManager.cs @@ -0,0 +1,310 @@ +// +// 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(loginName, character.Name).ConfigureAwait(false)) + { + await this.RemoveAndDisposeAsync(key, bot).ConfigureAwait(false); + return false; + } + + BotSkillProgressionPlugIn.CatchUpPendingProgress(bot); + + 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)) + { + await StopAndDisposeAsync(bot, key, "shutdown").ConfigureAwait(false); + } + } + } + + /// + /// 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); + } + } + catch (Exception ex) + { + // 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; + } + + /// + /// 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; + } + + // 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); + } + + /// + /// 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; + + // 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(BotResetHandler.GetEffectiveLevel) + .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 = 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 + && BotResetHandler.GetEffectiveLevel(candidates[next]) - 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}"; + + /// + /// 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 _); + await bot.DisposeAsync().ConfigureAwait(false); + } +} diff --git a/src/GameLogic/Bots/BotMasterHandler.cs b/src/GameLogic/Bots/BotMasterHandler.cs new file mode 100644 index 000000000..0da3d5f7c --- /dev/null +++ b/src/GameLogic/Bots/BotMasterHandler.cs @@ -0,0 +1,351 @@ +// +// 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; +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; + + /// 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(); + + /// + /// 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(player, 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(player, 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. 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), 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 !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 + /// 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); + } +} diff --git a/src/GameLogic/Bots/BotMiniGameHandler.cs b/src/GameLogic/Bots/BotMiniGameHandler.cs new file mode 100644 index 000000000..f811562e8 --- /dev/null +++ b/src/GameLogic/Bots/BotMiniGameHandler.cs @@ -0,0 +1,177 @@ +// +// 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); + + // The special characters (Magic Gladiator, Dark Lord, Rage Fighter, Summoner) enter the events in + // their own level bracket - the same distinction EnterMiniGameAction makes for a player. Judging + // them by the regular bracket kicked a qualified Magic Gladiator out of its leader's party as + // "below the minimum" (and would have let it in past its own maximum). + var isSpecialCharacter = bot.SelectedCharacter?.IsSpecialCharacter() == true; + var minimumLevel = isSpecialCharacter ? definition.MinimumSpecialCharacterLevel : definition.MinimumCharacterLevel; + var maximumLevel = isSpecialCharacter ? definition.MaximumSpecialCharacterLevel : definition.MaximumCharacterLevel; + + if (level < minimumLevel) + { + reason = $"level {level} is below the minimum of {minimumLevel}"; + return false; + } + + if (level > maximumLevel) + { + reason = $"level {level} is above the maximum of {maximumLevel}"; + 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/BotMuHelperSettings.cs b/src/GameLogic/Bots/BotMuHelperSettings.cs new file mode 100644 index 000000000..06fa4f851 --- /dev/null +++ b/src/GameLogic/Bots/BotMuHelperSettings.cs @@ -0,0 +1,212 @@ +// +// 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; + + // 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; + + /// + public int HealThresholdPercent => 60; + + /// + public bool UseDrainLife => false; + + /// + public bool UseHealPotion => true; + + /// + public int PotionThresholdPercent => 60; + + /// + // 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 => true; + + /// + public int HealPartyThresholdPercent => 60; + + /// + public bool UseDarkRaven => false; + + /// + public int DarkRavenMode => 0; + + /// + public int ObtainRange => 6; + + /// + public bool PickAllItems => 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; + + /// + 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; + + /// + /// Bots fight back when a player attacks them (see ). + public bool UseSelfDefense => true; + + /// + public bool AutoAcceptFriend => false; + + /// + 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; + + /// + /// + /// 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; + + /// + /// 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/BotNameGenerator.cs b/src/GameLogic/Bots/BotNameGenerator.cs new file mode 100644 index 000000000..f2bb9621a --- /dev/null +++ b/src/GameLogic/Bots/BotNameGenerator.cs @@ -0,0 +1,92 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Bots; + +using System.Globalization; +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(CultureInfo.InvariantCulture); + 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/GameLogic/Bots/BotNavigator.cs b/src/GameLogic/Bots/BotNavigator.cs new file mode 100644 index 000000000..b640fa32d --- /dev/null +++ b/src/GameLogic/Bots/BotNavigator.cs @@ -0,0 +1,1626 @@ +// +// 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.Diagnostics.CodeAnalysis; +using System.Threading; +using MUnique.OpenMU.DataModel.Entities; +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 +/// 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 +{ + /// + /// 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, + /// 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; + + /// + /// 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 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; + + /// + /// 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 + /// , 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; + + /// + /// 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 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 + /// 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 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); + + /// 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 (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); + + /// + /// 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 + /// 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. + /// + 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 + /// 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 int TravelPathFinderPoolSize = Math.Clamp(Environment.ProcessorCount / 3, 2, 6); + + private static readonly SemaphoreSlim TravelPathFinderPool = new(TravelPathFinderPoolSize, TravelPathFinderPoolSize); + + private static readonly ConcurrentBag TravelPathFinders = CreateTravelPathFinderPool(); + + private readonly OfflinePlayer _player; + private readonly CancellationTokenSource _cts = new(); + + private Timer? _timer; + private DateTime? _emptyGroundSince; + private int _isEvaluating; + private Point _destination; + private bool _hasDestination; + 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; + 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. + /// + /// The bot player. + public BotNavigator(OfflinePlayer player) + { + this._player = player; + } + + /// + /// 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() + { + // 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(cancellationToken), + null, + TimeSpan.FromMilliseconds(2000 + Rand.NextInt(0, 1500)), + 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 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; + } + + 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; + return Math.Abs(from.X - centerX) + Math.Abs(from.Y - centerY); + } + + 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); + this._player.OnAiTickSucceeded(); + } + catch (OperationCanceledException) + { + // expected during shutdown + } + catch (Exception ex) + { + this._player.Logger.LogError(ex, "Bot navigator error for {Account}.", this._player.AccountLoginName); + this._player.OnAiTickFailed(); + } + 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; + } + + // 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, cancellationToken).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); + + // 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. + // 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. Queued for the same reason: + // equipping mounts/unmounts item power-ups. + if (DateTime.UtcNow >= this._nextEquipCheckUtc) + { + 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 + // 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. + var inSafezone = map.Terrain.SafezoneMap[this._player.Position.X, this._player.Position.Y]; + + // 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) + { + this._lastPosition = this._player.Position; + this._lastMoveUtc = DateTime.UtcNow; + } + + // 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() && 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 + // 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 + // walker, and a new destination avoids re-stalling on the same blocked path. + this._lastMoveUtc = DateTime.UtcNow; + this._emptyGroundSince = null; + await this.PickGroundAndTravelAsync(map, cancellationToken).ConfigureAwait(false); + return; + } + + // A walk (travel hop or local combat move) is already in progress. + if (this._player.IsWalking) + { + 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 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. + if (await this.TryShoppingAsync(map, inSafezone, cancellationToken).ConfigureAwait(false)) + { + 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, cancellationToken).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). + // 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, cancellationToken).ConfigureAwait(false)) + { + return; + } + } + + // 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 + && 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) + { + this._emptyGroundSince = null; + return; + } + + // 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; + if (await this.TravelTowardAsync(map, monsterGround, cancellationToken).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. + if (this._hasDestination && this._player.GetDistanceTo(this._destination) > HuntingRange) + { + 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 + // 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; + } + + // 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; + if (!inSafezone) + { + this._emptyGroundSince ??= DateTime.UtcNow; + if (DateTime.UtcNow - this._emptyGroundSince < EmptyGroundGrace) + { + return; + } + } + + this._emptyGroundSince = null; + + if (leaderToFollow is null && await this.TryWarpToBetterMapAsync().ConfigureAwait(false)) + { + return; + } + + await this.PickGroundAndTravelAsync(map, cancellationToken).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, CancellationToken cancellationToken) + { + if (this._shoppingTarget is { } target) + { + if (this._player.GetDistanceTo(target) > MerchantTalkRange) + { + 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 + // 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; + } + + // 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; + 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)) + { + 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 + && 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.IsOnShoppingTrip = true; + this._player.Logger.LogInformation("Bot '{Name}' heads to the merchant for a shopping trip.", this._player.Name); + 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 + /// 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 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. + /// The token which aborts the travel wait on shutdown. + /// True, if the revenge march consumed this tick. + private async ValueTask TryRevengeMarchAsync(GameMap map, CancellationToken cancellationToken) + { + 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, 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. + this._player.ExpireRevenge("no route back to the death site"); + return false; + } + + 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, CancellationToken cancellationToken) + { + // 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, cancellationToken).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, cancellationToken).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 inside another mini game instance - + /// following into an event goes through the regular entry of , + /// never through a warp). + /// + 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 + || !ReferenceEquals(leader.CurrentMiniGame, this._player.CurrentMiniGame)) + { + 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, CancellationToken cancellationToken) + { + if (!ReferenceEquals(leader.CurrentMap, map)) + { + ExitGate? warpListGate = null; + if (leader.CurrentMap is { } targetMap + && !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 + // (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; + } + + // 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 + // 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() ?? warpListGate) 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, cancellationToken).ConfigureAwait(false); + return true; + } + + 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 + /// 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 (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)) + { + 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; + + // 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 () => + { + 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. Cleared + // optimistically - if the queued reset refuses, the bot merely re-picks its hunting ground. + 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 + /// 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(); + 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; + } + + 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; + } + + /// + /// 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, CancellationToken cancellationToken) + { + 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; + + // 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 + { + if (!TravelPathFinders.TryTake(out finder)) + { + finder = CreateTravelPathFinder(); + } + + finder.ResetPathFinder(); + path = finder.FindPath(position, destination, map.Terrain.AIgrid, true); + } + finally + { + if (finder is not null) + { + TravelPathFinders.Add(finder); + } + + TravelPathFinderPool.Release(); + } + + if (path is null || path.Count == 0) + { + // 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; + } + + 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[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); + } + + /// + /// 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, CancellationToken cancellationToken) + { + 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, cancellationToken).ConfigureAwait(false)) + { + this._hasDestination = false; + } + } + + /// + /// 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 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.Definition is { } ammoDefinition) + { + ammo.Durability = Math.Max((byte)1, ammoDefinition.Durability); + } + + 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 = Math.Max((byte)1, arrows.Durability); + 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) + { + 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 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) + { + return; + } + + // 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)) + { + charges += (int)(stackSize - potion.Durability); + potion.Durability = stackSize; + if (charges >= EmergencyPotionCharges) + { + return; + } + } + + while (charges < EmergencyPotionCharges) + { + 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; + } + } + + /// + /// 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 candidates = new List<(Monster Monster, double Distance)>(); + foreach (var attackable in map.GetAttackablesInRange(position, MonsterSeekRadius)) + { + if (attackable is not Monster monster + || !monster.IsAlive + || monster.IsAtSafezone()) + { + continue; + } + + if (!CombatHandler.IsSafeTarget(this._player, monster.Definition)) + { + // 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; + } + + candidates.Add((monster, monster.GetDistanceTo(position))); + } + + if (candidates.Count == 0) + { + return false; + } + + // 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 = chosen.Position; + 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); + } + } + + /// + /// 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 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. Monsters below + /// are not counted at all, which lets a mastered bot judge a map by + /// what it actually earns there (see ). + /// + /// 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) + { + if (!this.IsPermanentMonsterSpawn(area)) + { + continue; + } + + var level = GetMonsterLevel(area.MonsterDefinition!); + if (level >= minimumLevel && level > best && CombatHandler.IsSafeTarget(this._player, area.MonsterDefinition!)) + { + best = level; + } + } + + 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. + /// 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(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, minimumMonsterLevel)) + WarpImprovementMargin; + + foreach (var candidate in this._player.GameContext.Configuration.Maps) + { + if (ReferenceEquals(candidate, current)) + { + continue; + } + + var best = this.BestSafeLevel(candidate, minimumMonsterLevel); + if (best < threshold || candidate.TryGetRequirementError(this._player, out _)) + { + continue; + } + + if (this.TryGetLegalWarpGate(candidate, out var warpGate)) + { + gate = warpGate; + mapDefinition = candidate; + monsterLevel = best; + threshold = best + 1; // keep only a strictly-stronger map after this one + } + } + + return mapDefinition is not null; + } + + /// + /// 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 candidates = map.Definition.MonsterSpawns + .Where(this.IsPermanentMonsterSpawn) + .Select(a => (Area: a, Level: GetMonsterLevel(a.MonsterDefinition!))) + .Where(x => x.Level > 0) + .ToList(); + if (candidates.Count == 0) + { + return false; + } + + // 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 (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(); + } + else + { + var bottom = candidates.Min(x => x.Level); + 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; + 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, siteToAvoid, out ground); + } + + 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); + 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]) + { + 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; + 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/BotPartyHandler.cs b/src/GameLogic/Bots/BotPartyHandler.cs new file mode 100644 index 000000000..c93e17bab --- /dev/null +++ b/src/GameLogic/Bots/BotPartyHandler.cs @@ -0,0 +1,222 @@ +// +// 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 OpenMU's own + /// party action, which has no level gate at all. + /// + 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 + || HasHumanCompanion(bot) + || bot.PendingPartyInvite is not null) + { + return false; + } + + if (bot.IsOnShoppingTrip || bot.HasRevengeIntent || bot.CurrentMiniGame is not null) + { + // Busy - a player in the middle of an errand, a grudge or an event 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 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) + { + 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); + } + } + + /// + /// 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) + { + return false; + } + + var levelGap = Math.Abs(BotResetHandler.GetEffectiveLevel(bot) - BotResetHandler.GetEffectiveLevel(requester)); + return levelGap <= MaxEffectiveLevelGap; + } +} diff --git a/src/GameLogic/Bots/BotPlayer.cs b/src/GameLogic/Bots/BotPlayer.cs new file mode 100644 index 000000000..194071c03 --- /dev/null +++ b/src/GameLogic/Bots/BotPlayer.cs @@ -0,0 +1,115 @@ +// +// 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.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 +{ + /// + /// 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. + /// + /// The game context. + public BotPlayer(IGameContext gameContext) + : base(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; } + + /// + /// 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() + { + await this.StopNavigatorAsync().ConfigureAwait(false); + 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) + { + // 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, + ConsecutiveFailuresUntilRestart); + this.AwaitsFaultRestart = true; + } + } + + /// + 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/Bots/BotProgression.cs b/src/GameLogic/Bots/BotProgression.cs new file mode 100644 index 000000000..49176f34f --- /dev/null +++ b/src/GameLogic/Bots/BotProgression.cs @@ -0,0 +1,433 @@ +// +// 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 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 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 + /// 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]; + + /// + /// 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) + ? characterClass.NextGenerationClass + : 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): + /// + /// 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. + /// 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 + { + 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 - 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. + /// 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) + { + if (points <= 0 || weights.Count == 0) + { + yield break; + } + + 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 activeTotalWeight = 0; + var firstActive = -1; + for (var i = 0; i < weights.Count; i++) + { + 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; + } + + for (var i = 0; i < weights.Count; i++) + { + if (allocated[i] > 0) + { + yield return (weights[i].Stat, allocated[i]); + } + } + } + + /// + /// 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.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 + 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. + /// + /// 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) + { + 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. + /// + /// The "total" attribute to map. + 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 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, string characterName, bool resetMeta, 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; + } + + // 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; + } + + 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/BotPvpRules.cs b/src/GameLogic/Bots/BotPvpRules.cs new file mode 100644 index 000000000..8d4629326 --- /dev/null +++ b/src/GameLogic/Bots/BotPvpRules.cs @@ -0,0 +1,69 @@ +// +// 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 (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 +/// 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 damaging 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) + { + // 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) + { + return true; + } + + // Active self-defense: the target attacked this bot recently (SelfDefenseState is keyed + // (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)) + { + return true; + } + + return false; + } +} diff --git a/src/GameLogic/Bots/BotResetHandler.cs b/src/GameLogic/Bots/BotResetHandler.cs new file mode 100644 index 000000000..915e22cb9 --- /dev/null +++ b/src/GameLogic/Bots/BotResetHandler.cs @@ -0,0 +1,218 @@ +// +// 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 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 (). + /// 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) + + (int)(player.Attributes?[Stats.MasterLevel] ?? 0f); + 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/Bots/BotRevengePlugIn.cs b/src/GameLogic/Bots/BotRevengePlugIn.cs new file mode 100644 index 000000000..de1ef77e0 --- /dev/null +++ b/src/GameLogic/Bots/BotRevengePlugIn.cs @@ -0,0 +1,40 @@ +// +// 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 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.")] +[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 + && 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)) + { + bot.RegisterDeathByPlayer(killerPlayer); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/GameLogic/Bots/BotSelfDefensePlugIn.cs b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs new file mode 100644 index 000000000..852eee707 --- /dev/null +++ b/src/GameLogic/Bots/BotSelfDefensePlugIn.cs @@ -0,0 +1,36 @@ +// +// 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 + && bot.CurrentMiniGame is null // event fights (Chaos Castle) leave no grudge outside + && attacker is Player aggressor + && aggressor is not OfflinePlayer + && !ReferenceEquals(aggressor, attackable)) + { + bot.RegisterAggressor(aggressor); + } + } +} 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/src/GameLogic/Bots/BotShoppingHandler.cs b/src/GameLogic/Bots/BotShoppingHandler.cs new file mode 100644 index 000000000..86bf0f05a --- /dev/null +++ b/src/GameLogic/Bots/BotShoppingHandler.cs @@ -0,0 +1,245 @@ +// +// 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; + + /// 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(); + 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). + /// + /// The bot player. + 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(i => IsSellableJunk(player, i))) + { + return true; + } + + if (player.Money > 5000 && GetLowPotionKinds(player).Any()) + { + return true; + } + + return false; + } + + /// + /// 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) + { + // 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; + } + + /// + /// 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) + { + 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) + { + // 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(i => IsSellableJunk(player, i)).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 + && player.Money > MinZenReserve; 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++; + } + } + + // 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 + { + 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: 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(OfflinePlayer player, 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 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/Bots/BotSkillProgressionPlugIn.cs b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs new file mode 100644 index 000000000..ece66d10c --- /dev/null +++ b/src/GameLogic/Bots/BotSkillProgressionPlugIn.cs @@ -0,0 +1,177 @@ +// +// 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.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; + +/// +/// 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 = "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 +{ + 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) + { + if (player.Account?.IsBot != true + || player.SelectedCharacter?.CharacterClass is not { } + || player.SkillList is not { }) + { + return; + } + + // 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) + { + try + { + this.EvolveClassIfDue(player); + 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); + } + } + + /// + /// 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!; + var characterClass = character.CharacterClass!; + var points = character.LevelUpPoints; + if (points <= 0) + { + return; + } + + 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) + { + await IncreaseStatsAction.IncreaseStatsAsync(player, stat, (ushort)amount).ConfigureAwait(false); + } + } + } + + 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) + { + 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/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/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/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. /// 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/MuHelper/IMuHelperSettings.cs b/src/GameLogic/MuHelper/IMuHelperSettings.cs index 9dad4bdc1..2ade57c66 100644 --- a/src/GameLogic/MuHelper/IMuHelperSettings.cs +++ b/src/GameLogic/MuHelper/IMuHelperSettings.cs @@ -150,6 +150,50 @@ 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; } + + /// + /// 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; } + + /// + /// 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/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/BuffHandler.cs b/src/GameLogic/Offline/BuffHandler.cs index 2f9fe9ec9..bd2ff8abf 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. @@ -38,7 +40,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 +53,34 @@ public IList ConfiguredBuffIds return []; } + if (this._config.AutoSelectBuffs && this._player.SkillList is { } skillList) + { + // The learned buffs only change when a new skill is learned, so the list is cached and + // only rebuilt when the skill count changes - building it fresh with LINQ on every + // 500ms tick of hundreds of bots was measurable CPU for no benefit. + var skillCount = skillList.Skills.Count(); + if (this._cachedAutoBuffIds is null || skillCount != this._cachedAutoBuffSkillCount) + { + var learnedBuffs = skillList.Skills + .Where(s => s.Skill is { SkillType: SkillType.Buff, MagicEffectDef: not null }) + .Select(s => (int)s.Skill!.Number) + .OrderBy(n => n) + .Take(BuffSlotCount) + .ToList(); + + // Pad to the fixed slot count - the caller indexes all three slots; 0 means "slot empty". + while (learnedBuffs.Count < BuffSlotCount) + { + learnedBuffs.Add(0); + } + + this._cachedAutoBuffIds = learnedBuffs; + this._cachedAutoBuffSkillCount = skillCount; + } + + return this._cachedAutoBuffIds; + } + return [this._config.BuffSkill0Id, this._config.BuffSkill1Id, this._config.BuffSkill2Id]; } } @@ -246,8 +278,21 @@ 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/CombatHandler.cs b/src/GameLogic/Offline/CombatHandler.cs index 1ed9a3023..035cef885 100644 --- a/src/GameLogic/Offline/CombatHandler.cs +++ b/src/GameLogic/Offline/CombatHandler.cs @@ -4,7 +4,10 @@ namespace MUnique.OpenMU.GameLogic.Offline; +using System.Collections.Concurrent; +using MUnique.OpenMU.AttributeSystem; using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.Bots; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.PlayerActions.Skills; @@ -19,26 +22,82 @@ public sealed class CombatHandler { private const byte DefaultRange = 1; private const byte BowRange = 6; + + /// + /// 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.15f; + + /// + /// 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; + + /// + /// 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; + + /// + /// 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; + /// After this many consecutive failed approaches the target counts as unreachable. + private const int MaxApproachFailures = 3; + 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(); + /// + /// Cache of the combat-relevant stats of monster definitions (config data, immutable at runtime), + /// so the safety checks of hundreds of bots don't re-scan the attribute lists every tick. Keyed by + /// the monster number rather than the definition instance, so a configuration reload (which builds + /// new instances) reuses the entries instead of orphaning them. + /// + private static readonly ConcurrentDictionary MonsterStatsCache = new(); + private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; private readonly MovementHandler _movementHandler; - private readonly Point _originPosition; private readonly ConditionalSkillSlot[] _conditionalSkillSlots; private IAttackable? _currentTarget; private int _nearbyMonsterCount; private int _currentComboStep; private int _skillCooldownTicks; + private int _approachFailures; + private ushort _unreachableTargetId; + private DateTime _unreachableTargetUntilUtc = DateTime.MinValue; + private SkillEntry? _tickBestSkill; + private bool _tickBestSkillComputed; + private DateTime _engageAtUtc = DateTime.MinValue; /// /// Initializes a new instance of the class. @@ -46,13 +105,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), @@ -70,6 +127,19 @@ public CombatHandler(OfflinePlayer player, IMuHelperSettings? config, MovementHa /// public byte HuntingRange => CalculateHuntingRange(this._config); + /// + /// Gets the position to hunt around. Dynamic so bots can roam between hunting grounds. + /// + 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. /// @@ -85,6 +155,72 @@ public static byte CalculateHuntingRange(IMuHelperSettings? config) return (byte)Math.Max(DefaultRange, config.HuntingRange); } + /// + /// 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 (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. + /// + /// The bot player. + /// The monster definition. + public static bool IsSafeTarget(Player player, MonsterDefinition monster) + { + var (monsterLevel, averageDamage, monsterDefense, monsterHealth, monsterAttackRate) = GetMonsterCombatStats(monster); + if (monsterLevel <= 0 || player.Attributes is not { } attributes) + { + return false; + } + + // Reset-aware: on servers with the reset feature a freshly reset character is nominally a + // low level again but keeps the strength of its resets - the effective level keeps it from + // being locked out of the maps it just hunted on. + if (monsterLevel > BotResetHandler.GetEffectiveLevel(player)) + { + return false; + } + + var netHit = Math.Max(0f, averageDamage - attributes[Stats.DefensePvm]) * GetExpectedHitShare(player, monsterAttackRate); + if (netHit > SafeHitHealthShare * Math.Max(1f, attributes[Stats.MaximumHealth])) + { + return false; + } + + var attackPower = GetAttackPower(player); + if (attackPower <= monsterDefense * MinAttackAdvantage) + { + return false; + } + + return (attackPower - monsterDefense) * GetHitBudget(player, monsterLevel) >= monsterHealth; + } + + /// + /// 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; + } + /// /// Decrements the skill cooldown counter by one tick. /// @@ -101,6 +237,7 @@ public void DecrementCooldown() /// public async ValueTask PerformAttackAsync() { + var previousTarget = this._currentTarget; this.RefreshTarget(); if (this._currentTarget is null) @@ -108,13 +245,47 @@ 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. Only bots pay for the disguise: + // a player's own offline session must hunt exactly as fast as it always did. + if (this.IsBot) + { + if (!ReferenceEquals(previousTarget, this._currentTarget)) + { + this._engageAtUtc = DateTime.UtcNow.AddMilliseconds(Rand.NextInt(250, 900)); + } + + if (DateTime.UtcNow < this._engageAtUtc) + { + this._player.Rotation = this._player.GetDirectionTo(this._currentTarget); + return; + } + } + byte attackRange = this.GetEffectiveAttackRange(); if (!this.IsTargetInAttackRange(this._currentTarget, attackRange)) { - await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false); + if (await this._movementHandler.MoveCloserToTargetAsync(this._currentTarget, attackRange).ConfigureAwait(false)) + { + this._approachFailures = 0; + } + else if (++this._approachFailures >= MaxApproachFailures) + { + // The target is in (Euclidean) range but no walkable path leads to it - e.g. a monster + // across a wall or river. Blacklist it briefly and drop it, so the bot picks another + // target (or moves on) instead of standing in front of the obstacle forever. + this._unreachableTargetId = this._currentTarget.Id; + this._unreachableTargetUntilUtc = DateTime.UtcNow + UnreachableTargetBlacklistDuration; + this._currentTarget = null; + this._approachFailures = 0; + } + return; } + this._approachFailures = 0; + if (this._config?.UseCombo == true) { await this.ExecuteComboAttackAsync().ConfigureAwait(false); @@ -157,6 +328,75 @@ public async ValueTask PerformDrainLifeRecoveryAsync() } } + private static (int Level, float AverageDamage, float Defense, float Health, float AttackRate) GetMonsterCombatStats(MonsterDefinition monster) + { + return MonsterStatsCache.GetOrAdd( + monster.Number, + static (_, m) => + { + float GetValue(AttributeDefinition attribute) + => m.Attributes.FirstOrDefault(a => a.AttributeDefinition == attribute)?.Value ?? 0f; + + var level = (int)GetValue(Stats.Level); + var averageDamage = (GetValue(Stats.MinimumPhysBaseDmg) + GetValue(Stats.MaximumPhysBaseDmg)) / 2f; + return (level, averageDamage, GetValue(Stats.DefenseBase), GetValue(Stats.MaximumHealth), GetValue(Stats.AttackRatePvm)); + }, + monster); + } + + /// + /// 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 + /// 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; + + // The Min/Max wizardry damage is what a caster actually hits with (energy feeds it, see the + // class attribute relations); Stats.WizardryBaseDmg is only the bonus channel of the staff's + // rise - reading it made every caster look like it had no offense at all, so it never passed + // the checks below for anything but the starter maps and stayed there forever. + var wizardry = (attributes[Stats.MinimumWizBaseDmg] + attributes[Stats.MaximumWizBaseDmg]) / 2f; + var curse = (attributes[Stats.MinimumCurseBaseDmg] + attributes[Stats.MaximumCurseBaseDmg]) / 2f; + var skillDamage = 0; + foreach (var entry in player.SkillList?.Skills ?? []) + { + if (entry.Skill is { AttackDamage: > 0 } skill && skill.AttackDamage > skillDamage) + { + skillDamage = skill.AttackDamage; + } + } + + return Math.Max(physical, Math.Max(wizardry, curse)) + skillDamage; + } + private async ValueTask ExecuteAttackAsync(IAttackable target) { var skill = this.SelectAttackSkill(); @@ -170,6 +410,13 @@ 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) @@ -190,6 +437,26 @@ 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; + + // Self-defense has priority over farming: a player who recently attacked this bot becomes the + // target, as long as they are still viable and anywhere near. Without this the bot placidly + // keeps hitting monsters while a player kills it. The aggressor memory only sets the PRIORITY, + // though - whether the bot may actually strike is decided by BotPvpRules per attack: the grudge + // outlives the game's self-defense window, and striking outside of it would turn the bot into + // an outlaw (see BotPvpRules.IsLegalPvpTarget). + if (this._config?.UseSelfDefense == true + && this._player.RecentAggressor is { } aggressor + && aggressor.IsInRange(this._player.Position, this.HuntingRange * 2) + && BotPvpRules.IsLegalPvpTarget(this._player, aggressor)) + { + this._currentTarget = aggressor; + return; + } + if (this._currentTarget is { } t && !this.IsTargetStillValid(t)) { this._currentTarget = null; @@ -197,13 +464,24 @@ private void RefreshTarget() if (this._currentTarget is null) { - var monsters = this.GetAttackableMonstersInHuntingRange().ToList(); - this._currentTarget = monsters.MinBy(m => m.GetDistanceTo(this._player)); - this._nearbyMonsterCount = monsters.Count; + var targets = this.GetAttackableTargetsInHuntingRange().ToList(); + var candidates = targets + .Where(m => m.Id != this._unreachableTargetId || DateTime.UtcNow >= this._unreachableTargetUntilUtc) + .OrderBy(m => m.GetDistanceTo(this._player)) + .Take(this.IsBot ? 2 : 1) + .ToList(); + + // A bot chooses randomly among the two nearest candidates instead of strictly the nearest + // one: with many bots on one ground, deterministic nearest-first makes them all dogpile the + // same monster and roam as a pack, which looks distinctly bot-like and wastes damage on + // overkill. A player's own offline session keeps hitting the nearest monster - it is his + // character's hunting efficiency, not a crowd to camouflage. + this._currentTarget = candidates.SelectRandom(); + this._nearbyMonsterCount = targets.Count; } else { - this._nearbyMonsterCount = this.GetAttackableMonstersInHuntingRange().Count(); + this._nearbyMonsterCount = this.GetAttackableTargetsInHuntingRange().Count(); } } @@ -214,11 +492,42 @@ private IEnumerable GetAttackableMonstersInHuntingRange() return []; } - return map.GetAttackablesInRange(this._originPosition, this.HuntingRange) + return map.GetAttackablesInRange(this.OriginPosition, this.HuntingRange) .OfType() .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); @@ -226,17 +535,51 @@ 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 - && target.IsInRange(this._originPosition, this.HuntingRange); + && target.IsInRange(this.OriginPosition, this.HuntingRange); } private bool IsMonsterAttackable(Monster monster) { return monster.IsAlive && !monster.IsAtSafezone() - && monster.Definition.ObjectKind == NpcObjectKind.Monster; + && monster.Definition.ObjectKind == NpcObjectKind.Monster + && this.IsWithinSafeHuntLevel(monster); + } + + /// + /// With (server-side bots), the combat AI only + /// 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) + { + if (this._config?.OnlyHuntSafeMonsters != true) + { + return true; + } + + if (this._player.CurrentMiniGame is not null) + { + // Inside a mini game event the opposition is not the bot's choice - it fights what + // the event throws at it, like every other participant. Refusing "unsafe" waves + // would leave the bot idling in the middle of a Blood Castle. + return true; + } + + return IsSafeTarget(this._player, monster.Definition); } private async ValueTask ExecutePhysicalAttackAsync(IAttackable target) @@ -274,6 +617,36 @@ await this._player.ForEachWorldObserverAsync( { await monster.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); } + + // A player target is hit by the area skill as well. Outside of free-for-all events ONLY the + // target itself (the self-defense aggressor): any bystanding player in the blast radius is + // deliberately spared - a bot's self-defense must never splash uninvolved players, no + // matter what it casts. Inside a mini game with free player killing (Chaos Castle) there + // are no uninvolved players, so the skill splashes the other participants like any area + // skill would. The legality re-check right at the strike closes the last race: the target + // was legal when it was picked, but the situation may have changed in the meantime. + IEnumerable 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) + { + if (player.IsAlive && !player.IsAtSafezone() + && BotPvpRules.IsLegalPvpTarget(this._player, player)) + { + await player.AttackByAsync(this._player, skillEntry, isCombo).ConfigureAwait(false); + } + } } private async ValueTask ExecuteTargetedSkillAttackAsync(IAttackable target, Skill skill) @@ -290,10 +663,12 @@ 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; } @@ -316,9 +691,64 @@ 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._tickBestSkillComputed) + { + // Computed once per tick: both the attack-range check and the attack itself need it. + return this._tickBestSkill; + } + + this._tickBestSkillComputed = true; + if (this._player.SkillList is not { } skillList) + { + return null; + } + + SkillEntry? best = null; + var bestDamage = 0; + foreach (var entry in skillList.Skills) + { + if (entry.Skill is not { } skill || skill.AttackDamage <= 0) + { + continue; + } + + if (skill.SkillType is not (SkillType.DirectHit + or SkillType.AreaSkillAutomaticHits + or SkillType.AreaSkillExplicitHits + or SkillType.AreaSkillExplicitTarget)) + { + continue; + } + + if (skill.AttackDamage > bestDamage && this.HasEnoughResources(entry)) + { + best = entry; + bestDamage = skill.AttackDamage; + } + } + + this._tickBestSkill = best; + return best; + } + /// /// Evaluates whether the skill in the given slot should fire this tick. /// @@ -514,6 +944,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/GameLogic/Offline/HealingHandler.cs b/src/GameLogic/Offline/HealingHandler.cs index 480361433..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 = @@ -28,6 +31,13 @@ public sealed class HealingHandler ItemConstants.Apple, ]; + private static readonly ItemIdentifier[] ManaPotionPriority = + [ + ItemConstants.LargeManaPotion, + ItemConstants.MediumManaPotion, + ItemConstants.SmallManaPotion, + ]; + private readonly OfflinePlayer _player; private readonly IMuHelperSettings? _config; @@ -75,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..ecc6c6479 100644 --- a/src/GameLogic/Offline/ItemPickupHandler.cs +++ b/src/GameLogic/Offline/ItemPickupHandler.cs @@ -120,13 +120,22 @@ 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; + // A human's helper hoards every excellent/ancient piece - its owner sorts the treasure + // out later. A bot has no later: it cannot trade, so it only takes what it can actually + // wear as an upgrade; everything else would silt up its backpack until the loot pickup + // stops. + return this._player.Account?.IsBot != true + || Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item); } - if (this._config.PickExcellent && item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent)) + if (this._config.PickUpgradeItems && Bots.BotEquipmentHandler.IsUpgradeFor(this._player, item)) { + // The item is class-qualified gear which beats what the bot currently wears - worth picking + // up; the BotEquipmentHandler will equip it on one of its next passes. return true; } diff --git a/src/GameLogic/Offline/MovementHandler.cs b/src/GameLogic/Offline/MovementHandler.cs index 98ab06df5..a52b2460d 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; } @@ -69,13 +71,43 @@ 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)) + if (this._player.CurrentMap is { } map && target.IsInRange(this.OriginPosition, this.HuntingRange)) { - var walkTarget = map.Terrain.GetRandomCoordinate(target.Position, range); - await this.WalkToAsync(walkTarget).ConfigureAwait(false); + var walkTarget = GetApproachPoint(map, this._player.Position, target.Position, range); + return await this.WalkToAsync(walkTarget).ConfigureAwait(false); } + + return false; + } + + /// + /// 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); } /// @@ -120,7 +152,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 908e49f38..72ae371ef 100644 --- a/src/GameLogic/Offline/OfflinePlayer.cs +++ b/src/GameLogic/Offline/OfflinePlayer.cs @@ -7,16 +7,72 @@ namespace MUnique.OpenMU.GameLogic.Offline; using MUnique.OpenMU.DataModel.Entities; using MUnique.OpenMU.GameLogic.MuHelper; using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.PlugIns; /// /// An offline player that continues leveling after the real client disconnects. /// -public sealed class OfflinePlayer : Player +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 + /// 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); + + /// + /// 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; + /// + /// 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. + /// 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. /// @@ -36,6 +92,71 @@ 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; + + /// + /// 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(); + + /// + /// 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._aggression is { } aggression + && DateTime.UtcNow - aggression.AtUtc <= AggressionMemory + && aggression.Aggressor.IsAlive + && !aggression.Aggressor.IsAtSafezone()) + { + return aggression.Aggressor; + } + + return null; + } + } + + /// + /// 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. /// @@ -70,6 +191,8 @@ public async ValueTask InitializeAsync(string loginName, string characterN await this.ClientReadyAfterMapChangeAsync().ConfigureAwait(false); + this.HuntingOrigin = this.Position; + this.StartIntelligence(); this.Logger.LogDebug( @@ -90,11 +213,188 @@ public async ValueTask InitializeAsync(string loginName, string characterN /// /// Stops the offline player and removes it from the world. /// - public async ValueTask StopAsync() + 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._aggression = new Aggression(aggressor, 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 keeps the killer prioritized (struck only when legal, see ), + /// 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 . + /// + 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); + } + } + } + + /// + /// 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() { @@ -132,6 +432,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. @@ -146,9 +455,24 @@ private async ValueTask SetupCharacterAsync(Character character) await this.SetSelectedCharacterAsync(character).ConfigureAwait(false); } - private void StartIntelligence() - { - this._intelligence = new OfflinePlayerMuHelper(this); - this._intelligence.Start(); - } -} \ 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); + + /// + /// The most recent aggression against this bot: who attacked and when. + /// + private sealed record Aggression(Player Aggressor, DateTime AtUtc); +} diff --git a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs index cc4027b67..73bc9a13e 100644 --- a/src/GameLogic/Offline/OfflinePlayerMuHelper.cs +++ b/src/GameLogic/Offline/OfflinePlayerMuHelper.cs @@ -46,14 +46,13 @@ public sealed class OfflinePlayerMuHelper : AsyncDisposable public OfflinePlayerMuHelper(OfflinePlayer player) { this._player = player; - var originalPosition = player.Position; var config = player.MuHelperSettings; this._buffHandler = new BuffHandler(player, config); this._healingHandler = new HealingHandler(player, config); this._itemPickupHandler = new ItemPickupHandler(player, config); - this._movementHandler = new MovementHandler(player, config, originalPosition); - this._combatHandler = new CombatHandler(player, config, this._movementHandler, originalPosition); + this._movementHandler = new MovementHandler(player, config); + this._combatHandler = new CombatHandler(player, config, this._movementHandler); this._repairHandler = new RepairHandler(player, config); this._zenHandler = new ZenConsumptionHandler(player); this._petHandler = new PetHandler(player, config); @@ -120,10 +119,17 @@ private void OnPlayerDied(DeathInformation e) { this._player.Logger.LogDebug("Offline player '{Name}' died. Killer: {KillerName}.", this._player.Name, e.KillerName); this._isDead = true; + + // Do not cancel the loop here: a bot needs to keep ticking so it can resume after respawning. + // For a normal offline session the tick stops the session on respawn, which disposes (and cancels) this helper. } private async Task RunLoopAsync(CancellationToken cancellationToken) { + // Randomize the loop phase, so hundreds of concurrently started players don't all tick on the + // same 500ms boundary - smoother server load and less robotic synchrony between them. + await Task.Delay(Rand.NextInt(0, 500), cancellationToken).ConfigureAwait(false); + while (await this._timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false)) { cancellationToken.ThrowIfCancellationRequested(); @@ -136,6 +142,7 @@ private async Task SafeTickAsync(CancellationToken cancellationToken) try { await this.TickAsync(cancellationToken).ConfigureAwait(false); + this._player.OnAiTickSucceeded(); } catch (OperationCanceledException) { @@ -144,11 +151,16 @@ 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(); } } private async ValueTask TickAsync(CancellationToken cancellationToken) { + // Actions queued from outside the tick (e.g. skill learning on level-up) run here, serialized + // with the combat handler - so nothing mutates the skill list while combat is enumerating it. + await this._player.DrainPendingBotActionsAsync().ConfigureAwait(false); + if (await this.HandleDeathAsync().ConfigureAwait(false)) { return; @@ -215,6 +227,19 @@ 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. + // A death by a player's hand may have left a pending revenge - arm it now (the navigator + // then marches the bot back to its death site instead of picking a hunting ground). + this._isDead = false; + this._player.HuntingOrigin = this._player.Position; + this._player.ArmRevengeAfterRespawn(); + this._player.Logger.LogInformation("Bot '{Name}' respawned; resuming.", this._player.Name); + return false; + } + if (this._player.Account?.LoginName is { } loginName) { this._player.Logger.LogInformation("Offline player died and successfully respawned. Stopping session for {0}.", loginName); diff --git a/src/GameLogic/Offline/ZenConsumptionHandler.cs b/src/GameLogic/Offline/ZenConsumptionHandler.cs index 0cd5d7709..85f170168 100644 --- a/src/GameLogic/Offline/ZenConsumptionHandler.cs +++ b/src/GameLogic/Offline/ZenConsumptionHandler.cs @@ -34,6 +34,14 @@ public ZenConsumptionHandler(OfflinePlayer player) /// true if the player can continue; false if insufficient Zen. public async ValueTask DeductZenAsync() { + // Bots are exempt from the PC-Cafe fee: they don't accumulate Zen fast enough + // to cover it and would otherwise go bankrupt and stop. Human offline-leveling + // players (IsBot == false) keep paying as before. + if (this._player.Account?.IsBot == true) + { + return true; + } + if (DateTime.UtcNow - this._lastPayTimestamp < this._configuration.PayInterval) { return true; 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/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/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)) { 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); diff --git a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs index e1dddb0d2..58ab6225f 100644 --- a/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs +++ b/src/GameServer/RemoteView/MuHelper/MuHelperSettings.cs @@ -155,6 +155,26 @@ 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; + + /// + /// 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() { 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 new file mode 100644 index 000000000..3f738e560 --- /dev/null +++ b/src/Persistence/EntityFramework/Migrations/20260627120000_AddAccountIsBot.cs @@ -0,0 +1,35 @@ +// +// 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.Migrations; + + /// + 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 6d8e751fa..3c4db30a3 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"); 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/BotJewelHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs new file mode 100644 index 000000000..31a38d5e1 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotJewelHandlerTest.cs @@ -0,0 +1,197 @@ +// +// 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); + + // 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); + + 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)); + } + + /// + /// 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. + /// + /// 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; + } +} diff --git a/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs new file mode 100644 index 000000000..46be4c0f1 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/BotMiniGameHandlerTest.cs @@ -0,0 +1,175 @@ +// +// 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); + } + + /// + /// 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. + /// + /// 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; + } +} 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); + } +} 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)); + } +} 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; + } +} diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs new file mode 100644 index 000000000..47c7bfb92 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offline/BotMasterHandlerTests.cs @@ -0,0 +1,267 @@ +// +// 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)); + } + + /// + /// 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. + /// + [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; + } +} diff --git a/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs new file mode 100644 index 000000000..291311d7c --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/Offline/BotProgressionTests.cs @@ -0,0 +1,79 @@ +// +// 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) }; + + // Every stat is capped at the same value, so which one is asked for does not matter. + Func capacityOf = _ => 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); + } + } +} 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)); + } +} 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); 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; + } +}