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