baseline: OpenMU upstream b5a0961 (fresh source)

This commit is contained in:
Acentech Dev
2026-07-14 19:00:35 +03:00
parent 450402e47d
commit 36fc125d5c
11968 changed files with 748705 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
// <copyright file="ArrayExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Extensions for arrays.
/// </summary>
public static class ArrayExtensions
{
/// <summary>
/// Clears the elements of an array to the default values of the element type.
/// </summary>
/// <typeparam name="T">The array element type.</typeparam>
/// <param name="array">The array.</param>
public static void ClearToDefaults<T>(this T[] array)
{
for (int i = 0; i < array.Length; i++)
{
array[i] = default!;
}
}
/// <summary>
/// Clears the elements of an array to the default values of the element type.
/// </summary>
/// <typeparam name="T">The array element type.</typeparam>
/// <param name="array">The array.</param>
public static void ClearToDefaults<T>(this T[,] array)
{
for (int i = 0; i < array.GetLength(0); i++)
{
for (int j = 0; j < array.GetLength(1); j++)
{
array[i, j] = default!;
}
}
}
}

View File

@@ -0,0 +1,54 @@
// <copyright file="AsyncDisposable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Base class for classes which want to implement <see cref="IAsyncDisposable"/>.
/// </summary>
public class AsyncDisposable : Disposable, IAsyncDisposable
{
private bool _isDisposing;
/// <summary>
/// Gets a value indicating whether this instance is disposing.
/// </summary>
/// <value>
/// <c>true</c> if this instance is disposing; otherwise, <c>false</c>.
/// </value>
public new bool IsDisposing
{
get => this._isDisposing || base.IsDisposing;
private set => this._isDisposing = value;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (!this.IsDisposed && !this.IsDisposing)
{
this.IsDisposing = true;
try
{
await this.DisposeAsyncCore().ConfigureAwait(false);
this.Dispose(true);
GC.SuppressFinalize(this);
}
finally
{
this.IsDisposing = false;
}
}
}
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or
/// resetting unmanaged resources asynchronously.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "VSTHRD200:Use \"Async\" suffix for async methods", Justification = "This naming is suggested by microsoft itself.")]
protected virtual ValueTask DisposeAsyncCore()
{
return ValueTask.CompletedTask;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
// <copyright file="AttributeSystemExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Extensions for <see cref="IAttributeSystem"/>s.
/// </summary>
public static class AttributeSystemExtensions
{
/// <summary>
/// Gets the attribute for a dummy attribute to be used internally for durations.
/// </summary>
private static AttributeDefinition DurationDummy { get; } = new(new Guid("23D069C3-24D8-4277-8FDC-D82F0AF64037"), "Duration Dummy", "A dummy attribute to be used internally for durations.");
/// <summary>
/// Gets the attribute for a dummy attribute to be used internally for chances.
/// </summary>
private static AttributeDefinition ChanceDummy { get; } = new(new Guid("E6B9E6A5-5800-40EA-80B7-14C2C06392A6"), "Chance Dummy", "A dummy attribute to be used internally for chances.");
/// <summary>
/// Creates a new element on this attribute system with the specified power up value.
/// </summary>
/// <param name="attributeSystem">The attribute system.</param>
/// <param name="powerUpDefinition">The power up definition.</param>
/// <returns>The added element.</returns>
public static IElement CreateDurationElement(this IAttributeSystem attributeSystem, PowerUpDefinitionValue powerUpDefinition)
{
return attributeSystem.CreateElement(powerUpDefinition, DurationDummy);
}
/// <summary>
/// Creates a new element on this attribute system with the specified power up value.
/// </summary>
/// <param name="attributeSystem">The attribute system.</param>
/// <param name="powerUpDefinition">The power up definition.</param>
/// <returns>The added element.</returns>
public static IElement CreateChanceElement(this IAttributeSystem attributeSystem, PowerUpDefinitionValue powerUpDefinition)
{
return attributeSystem.CreateElement(powerUpDefinition, ChanceDummy);
}
/// <summary>
/// Creates a new element on this attribute system with the specified power up value.
/// </summary>
/// <param name="attributeSystem">The attribute system.</param>
/// <param name="powerUpDefinition">The power up definition.</param>
/// <returns>The added element.</returns>
public static IElement CreateElement(this IAttributeSystem attributeSystem, PowerUpDefinition powerUpDefinition)
{
var value = powerUpDefinition.Boost ?? throw Error.NotInitializedProperty(powerUpDefinition, nameof(powerUpDefinition.Boost));
var targetDefinition = powerUpDefinition.TargetAttribute ?? throw Error.NotInitializedProperty(powerUpDefinition, nameof(powerUpDefinition.TargetAttribute));
return attributeSystem.CreateElement(value, targetDefinition);
}
/// <summary>
/// Creates a new element on this attribute system with the specified power up value.
/// </summary>
/// <param name="attributeSystem">The attribute system.</param>
/// <param name="value">The value.</param>
/// <param name="targetDefinition">The attribute.</param>
/// <returns>The added element.</returns>
public static IElement CreateElement(this IAttributeSystem attributeSystem, PowerUpDefinitionValue value, AttributeDefinition targetDefinition)
{
var relations = value.RelatedValues;
var result = value.ConstantValue;
if (relations?.Any() ?? false)
{
var elements = relations
.Select(r => new AttributeRelationshipElement(
new[] { attributeSystem.GetOrCreateAttribute(r.InputAttribute ?? throw new InvalidOperationException($"InputAttribute value not set for AttributeRelationship {r.GetId()}.")) },
r.GetOperandElement(attributeSystem),
r.InputOperator)
{
AggregateType = result.AggregateType,
})
.Cast<IElement>();
if (value.ConstantValue is not null)
{
elements = elements.Concat(value.ConstantValue.GetAsEnumerable());
}
var composableResult = new ComposableAttribute(targetDefinition, result.AggregateType, value.MaximumValue);
elements.ForEach(element => composableResult.AddElement(element));
return composableResult;
}
if (result is null)
{
throw new ArgumentException($"The passed {nameof(PowerUpDefinitionValue)} doesn't have a constant value or related values.", nameof(value));
}
return result;
}
}

View File

@@ -0,0 +1,129 @@
// <copyright file="ItemAwareAttributeSystem.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// An attribute system which considers items of a character.
/// </summary>
public sealed class ItemAwareAttributeSystem : AttributeSystem, IDisposable
{
/// <summary>
/// Initializes a new instance of the <see cref="ItemAwareAttributeSystem" /> class.
/// </summary>
/// <param name="account">The account.</param>
/// <param name="character">The character.</param>
/// <param name="gameConfiguration">The game configuration with global attributes.</param>
public ItemAwareAttributeSystem(Account account, Character character, GameConfiguration gameConfiguration)
: base(
character.Attributes.Concat(account.Attributes),
character.CharacterClass!.BaseAttributeValues.Concat(gameConfiguration.GlobalBaseAttributeValues),
character.CharacterClass.AttributeCombinations.Concat(gameConfiguration.GlobalAttributeCombinations))
{
this.ItemPowerUps = new Dictionary<Item, IReadOnlyList<PowerUpWrapper>>();
foreach (var attribute in this)
{
this.OnAttributeAdded(attribute);
}
}
/// <summary>
/// Occurs when an attribute value changed.
/// </summary>
public event EventHandler<IAttribute>? AttributeValueChanged;
/// <summary>
/// Gets the item power ups.
/// </summary>
public IDictionary<Item, IReadOnlyList<PowerUpWrapper>> ItemPowerUps { get; }
/// <summary>
/// Gets or sets the item set power ups.
/// </summary>
public IReadOnlyList<PowerUpWrapper>? ItemSetPowerUps { get; set; }
/// <inheritdoc />
public void Dispose()
{
this.AttributeValueChanged = null;
foreach (var attribute in this)
{
attribute.ValueChanged -= this.OnAttributeValueChanged;
}
foreach (var powerUpWrapper in this.ItemPowerUps.SelectMany(p => p.Value))
{
powerUpWrapper.Dispose();
}
this.ItemPowerUps.Clear();
if (this.ItemSetPowerUps is { } itemSetPowerUps)
{
foreach (var powerUp in itemSetPowerUps)
{
powerUp.Dispose();
}
this.ItemSetPowerUps = null;
}
}
/// <inheritdoc />
public override string ToString()
{
var stringBuilder = new StringBuilder();
stringBuilder.Append(base.ToString());
stringBuilder.AppendLine("Item Power Ups:");
foreach (var (item, powerUps) in this.ItemPowerUps)
{
stringBuilder.Append(" ").AppendLine(item.ToString());
foreach (var powerUp in powerUps)
{
stringBuilder.Append(" ").AppendLine(powerUp.ToString());
}
}
if (this.ItemSetPowerUps != null)
{
stringBuilder.AppendLine("Item Set Power Ups:");
foreach (var attribute in this.ItemSetPowerUps)
{
stringBuilder.Append(" ").AppendLine(attribute.ToString());
}
}
return stringBuilder.ToString();
}
/// <inheritdoc />
protected override void OnAttributeAdded(IAttribute attribute)
{
base.OnAttributeAdded(attribute);
attribute.ValueChanged += this.OnAttributeValueChanged;
}
/// <inheritdoc />
protected override void OnAttributeRemoved(IAttribute attribute)
{
attribute.ValueChanged -= this.OnAttributeValueChanged;
base.OnAttributeRemoved(attribute);
}
/// <summary>
/// Called when an attribute value changed. Forwards the event to <see cref="AttributeValueChanged"/>.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="e">The <see cref="EventArgs"/> instance containing the event data.</param>
private void OnAttributeValueChanged(object? sender, EventArgs e)
{
if (sender is IAttribute attribute)
{
this.AttributeValueChanged?.Invoke(this, attribute);
}
}
}

View File

@@ -0,0 +1,208 @@
// <copyright file="MonsterAttributeHolder.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Attributes;
using System.Collections.Concurrent;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.NPC;
/// <summary>
/// The attribute system for monsters, which is considering monster definitions.
/// </summary>
public class MonsterAttributeHolder : IAttributeSystem
{
private static readonly IDictionary<AttributeDefinition, Func<AttackableNpcBase, float>> StatMapping =
new Dictionary<AttributeDefinition, Func<AttackableNpcBase, float>>
{
{ Stats.CurrentHealth, m => m.Health },
{ Stats.DefensePvm, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
{ Stats.DefensePvp, m => m.Attributes.GetValueOfAttribute(Stats.DefenseBase) + ((m as Monster)?.SummonedBy?.Attributes?[Stats.SummonedMonsterDefenseIncrease] ?? 0) },
{ Stats.DamageReceiveDecrement, m => 1.0f },
{ Stats.AttackDamageIncrease, m => 1.0f },
{ Stats.MovementSpeedFactor, m => 1.0f },
{ Stats.ShieldBypassChance, m => 1.0f },
{ Stats.DefenseDecrement, m => 1.0f - m.Attributes.GetValueOfAttribute(Stats.InnovationDefDecrement) },
};
private static readonly IDictionary<AttributeDefinition, Action<AttackableNpcBase, float>> SetterMapping =
new Dictionary<AttributeDefinition, Action<AttackableNpcBase, float>>
{
{ Stats.CurrentHealth, (m, v) => m.Health = (int)v },
};
private static readonly ConcurrentDictionary<MonsterDefinition, IDictionary<AttributeDefinition, float>> MonsterStatAttributesCache = new();
private readonly AttackableNpcBase _monster;
private readonly object _attributesLock = new();
private IDictionary<AttributeDefinition, float> _statAttributes;
/// <summary>
/// Attribute dictionary of a monster instance.
/// Most monster instances don't have additional attributes, so we just instantiate one if needed.
/// </summary>
private IDictionary<AttributeDefinition, IComposableAttribute>? _attributes;
/// <summary>
/// Initializes a new instance of the <see cref="MonsterAttributeHolder"/> class.
/// </summary>
/// <param name="monster">The monster.</param>
public MonsterAttributeHolder(AttackableNpcBase monster)
{
this._monster = monster;
this._statAttributes = GetStatAttributeOfMonster(monster.Definition);
}
/// <inheritdoc/>
public float this[AttributeDefinition attributeDefinition]
{
get => this.GetValueOfAttribute(attributeDefinition);
set
{
if (SetterMapping.TryGetValue(attributeDefinition, out var setAction))
{
setAction(this._monster, value);
}
}
}
/// <inheritdoc/>
public float GetValueOfAttribute(AttributeDefinition attributeDefinition)
{
IDictionary<AttributeDefinition, IComposableAttribute>? attributes;
lock (this._attributesLock)
{
attributes = this._attributes;
}
if (attributes is not null
&& attributes.TryGetValue(attributeDefinition, out var attribute))
{
return attribute.Value;
}
if (this._statAttributes.TryGetValue(attributeDefinition, out float value))
{
return value;
}
if (StatMapping.TryGetValue(attributeDefinition, out var mappingFunction))
{
return mappingFunction(this._monster);
}
return 0;
}
/// <inheritdoc/>
public void AddElement(IElement element, AttributeDefinition targetAttribute)
{
var attributeDictionary = this.GetAttributeDictionary();
if (!attributeDictionary.TryGetValue(targetAttribute, out var attribute))
{
attribute = new ComposableAttribute(targetAttribute);
var attrValue = this.GetValueOfAttribute(targetAttribute);
var nullValue = element.AggregateType == AggregateType.Multiplicate ? 1 : 0;
attrValue = Math.Abs(attrValue) < 0.01f ? nullValue : attrValue;
attribute.AddElement(new SimpleElement { Value = attrValue });
attributeDictionary.Add(targetAttribute, attribute);
}
attribute.AddElement(element);
}
/// <inheritdoc/>
public void RemoveElement(IElement element, AttributeDefinition targetAttribute)
{
IDictionary<AttributeDefinition, IComposableAttribute>? attributes;
lock (this._attributesLock)
{
attributes = this._attributes;
}
if (attributes is null)
{
return;
}
if (attributes.TryGetValue(targetAttribute, out var attribute))
{
attribute.RemoveElement(element);
if (!attribute.Elements.Skip(1).Any())
{
attributes.Remove(targetAttribute);
}
}
if (attributes.Count == 0)
{
lock (this._attributesLock)
{
this._attributes = null;
}
}
}
/// <inheritdoc/>
public void AddAttributeRelationship(AttributeRelationship combination, IAttributeSystem sourceAttributeHolder, AggregateType aggregateType)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition)
{
throw new NotImplementedException();
}
/// <summary>
/// Reloads the stat attributes from the (possibly changed) <see cref="MonsterDefinition"/>,
/// so that configuration changes take effect on the running game server.
/// </summary>
public void ApplyChanges()
{
// When many instances of the same monster are spawned, all of them get notified about the
// same change. The first one rebuilds the shared cache; the others just adopt the new one.
if (MonsterStatAttributesCache.TryGetValue(this._monster.Definition, out var cached)
&& !ReferenceEquals(cached, this._statAttributes))
{
this._statAttributes = cached;
return;
}
var statAttributes = BuildStatAttributes(this._monster.Definition);
MonsterStatAttributesCache[this._monster.Definition] = statAttributes;
this._statAttributes = statAttributes;
}
private static IDictionary<AttributeDefinition, float> GetStatAttributeOfMonster(MonsterDefinition monsterDefinition)
{
return MonsterStatAttributesCache.GetOrAdd(monsterDefinition, BuildStatAttributes);
}
private static IDictionary<AttributeDefinition, float> BuildStatAttributes(MonsterDefinition monsterDefinition)
{
return monsterDefinition.Attributes.ToDictionary(
m => m.AttributeDefinition ?? throw Error.NotInitializedProperty(m, nameof(m.AttributeDefinition)),
m => m.Value);
}
private IDictionary<AttributeDefinition, IComposableAttribute> GetAttributeDictionary()
{
lock (this._attributesLock)
{
var attributes = this._attributes;
if (attributes is null)
{
attributes = new Dictionary<AttributeDefinition, IComposableAttribute>();
this._attributes = attributes;
}
return attributes;
}
}
}

View File

@@ -0,0 +1,105 @@
// <copyright file="PowerUpWrapper.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
/// <summary>
/// A wrapper class which adapts power ups to <see cref="IElement"/> instances.
/// </summary>
public sealed class PowerUpWrapper : IElement, IDisposable
{
private readonly IElement _element;
private readonly AggregateType? _aggregateType;
private ComposableAttribute? _parentAttribute;
/// <summary>
/// Initializes a new instance of the <see cref="PowerUpWrapper"/> class.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="targetAttribute">The target attribute.</param>
/// <param name="attributeHolder">The attribute holder.</param>
/// <param name="aggregateType">The aggregate type that should override the <paramref name="element"/>'s.</param>
public PowerUpWrapper(IElement element, AttributeDefinition targetAttribute, AttributeSystem attributeHolder, AggregateType? aggregateType = null)
{
this._parentAttribute = attributeHolder.GetComposableAttribute(targetAttribute);
if (this._parentAttribute is null)
{
throw new ArgumentException($"Target attribute [{targetAttribute}] is not composable", nameof(targetAttribute));
}
this._element = element;
this._aggregateType = aggregateType;
this._parentAttribute.AddElement(this);
this._element.ValueChanged += this.OnValueChanged;
}
/// <inheritdoc/>
public event EventHandler? ValueChanged;
/// <inheritdoc/>
public float Value => this._element.Value;
/// <inheritdoc/>
public AggregateType AggregateType => this._aggregateType ?? this._element.AggregateType;
/// <summary>
/// Creates elements by a <see cref="PowerUpDefinition"/>.
/// </summary>
/// <param name="powerUpDef">The power up definition.</param>
/// <param name="attributeHolder">The attribute holder.</param>
/// <param name="aggregateType">The specific aggregate type. If not specified, the aggregate type of the <paramref name="powerUpDef"/>'s constant value will be used.</param>
/// <returns>The elements which represent the power-up.</returns>
public static IEnumerable<PowerUpWrapper> CreateByPowerUpDefinition(PowerUpDefinition powerUpDef, AttributeSystem attributeHolder, AggregateType? aggregateType = null)
{
if (powerUpDef.Boost?.ConstantValue != null)
{
yield return new PowerUpWrapper(
powerUpDef.Boost.ConstantValue,
powerUpDef.TargetAttribute ?? throw Error.NotInitializedProperty(powerUpDef, nameof(PowerUpDefinition.TargetAttribute)),
attributeHolder,
aggregateType);
}
if (powerUpDef.Boost?.RelatedValues != null)
{
foreach (var relationship in powerUpDef.Boost.RelatedValues)
{
var aggregType = powerUpDef.Boost?.ConstantValue.AggregateType ?? AggregateType.AddRaw;
yield return new PowerUpWrapper(
attributeHolder.CreateRelatedAttribute(relationship, attributeHolder, aggregType),
powerUpDef.TargetAttribute ?? throw Error.NotInitializedProperty(powerUpDef, nameof(PowerUpDefinition.TargetAttribute)),
attributeHolder,
aggregateType);
}
}
}
/// <inheritdoc/>
public void Dispose()
{
if (this._parentAttribute != null)
{
this._parentAttribute.RemoveElement(this);
this.ValueChanged = null;
this._parentAttribute = null;
this._element.ValueChanged -= this.OnValueChanged;
}
}
/// <inheritdoc/>
public override string? ToString()
{
return $"{this._parentAttribute?.Definition.Designation}: {this._element}{(this._aggregateType is { } aggreg ? $"->({aggreg})" : string.Empty)}";
}
private void OnValueChanged(object? sender, EventArgs eventArgs)
{
this.ValueChanged?.Invoke(sender, eventArgs);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,102 @@
// <copyright file="TrapAttributeHolder.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
/// <summary>
/// The attribute system for traps, which is considering monster definitions.
/// </summary>
public class TrapAttributeHolder : IAttributeSystem
{
private static readonly IDictionary<AttributeDefinition, Func<Trap, float>> StatMapping =
new Dictionary<AttributeDefinition, Func<Trap, float>>
{
{ Stats.AttackDamageIncrease, _ => 1.0f },
{ Stats.ShieldBypassChance, _ => 1.0f },
};
private static readonly IDictionary<MonsterDefinition, IDictionary<AttributeDefinition, float>> MonsterStatAttributesCache = new Dictionary<MonsterDefinition, IDictionary<AttributeDefinition, float>>();
private readonly Trap _trap;
private readonly IDictionary<AttributeDefinition, float> _statAttributes;
/// <summary>
/// Initializes a new instance of the <see cref="TrapAttributeHolder"/> class.
/// </summary>
/// <param name="trap">The trap.</param>
public TrapAttributeHolder(Trap trap)
{
this._trap = trap;
this._statAttributes = GetStatAttributeOfMonster(trap.Definition);
}
/// <inheritdoc/>
public float this[AttributeDefinition attributeDefinition]
{
get => this.GetValueOfAttribute(attributeDefinition);
set
{
// can't set anything for Traps.
}
}
/// <inheritdoc/>
public float GetValueOfAttribute(AttributeDefinition attributeDefinition)
{
if (this._statAttributes.TryGetValue(attributeDefinition, out float value))
{
return value;
}
if (StatMapping.TryGetValue(attributeDefinition, out var mappingFunction))
{
return mappingFunction(this._trap);
}
return 0;
}
/// <inheritdoc/>
public void AddElement(IElement element, AttributeDefinition targetAttribute)
{
throw new NotImplementedException();
}
/// <inheritdoc/>
public void RemoveElement(IElement element, AttributeDefinition targetAttribute)
{
throw new NotImplementedException();
}
/// <inheritdoc/>
public void AddAttributeRelationship(AttributeRelationship combination, IAttributeSystem sourceAttributeHolder, AggregateType aggregateType)
{
throw new NotImplementedException();
}
/// <inheritdoc />
public IElement GetOrCreateAttribute(AttributeDefinition attributeDefinition)
{
throw new NotImplementedException();
}
private static IDictionary<AttributeDefinition, float> GetStatAttributeOfMonster(MonsterDefinition monsterDef)
{
if (!MonsterStatAttributesCache.TryGetValue(monsterDef, out var result))
{
result = monsterDef.Attributes.ToDictionary(
m => m.AttributeDefinition ?? throw Error.NotInitializedProperty(m, nameof(PowerUpDefinition.TargetAttribute)),
m => m.Value);
MonsterStatAttributesCache.Add(monsterDef, result);
}
return result;
}
}

View File

@@ -0,0 +1,44 @@
// <copyright file="BackupItemStorage.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// A storage which keeps items backed up - to be able to revert them after cancellation of an action.
/// </summary>
public class BackupItemStorage
{
private readonly IList<(Item Item, byte Slot)> _initialItemStates;
/// <summary>
/// Initializes a new instance of the <see cref="BackupItemStorage"/> class.
/// </summary>
/// <param name="itemStorage">The item storage.</param>
public BackupItemStorage(ItemStorage itemStorage)
{
var items = new List<Item>();
items.AddRange(itemStorage.Items);
this.Items = items;
this.Money = itemStorage.Money;
this._initialItemStates = this.Items.Select(item => (item, item.ItemSlot)).ToList();
}
/// <summary>
/// Gets or sets the items.
/// </summary>
public ICollection<Item> Items { get; set; }
/// <summary>
/// Gets or sets the money.
/// </summary>
public int Money { get; set; }
/// <summary>
/// Restores the initial item states.
/// </summary>
public void RestoreItemStates()
{
this._initialItemStates.ForEach(state => state.Item.ItemSlot = state.Slot);
}
}

View File

@@ -0,0 +1,73 @@
// <copyright file="BleedingMagicEffect.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Timers;
using MUnique.OpenMU.AttributeSystem;
/// <summary>
/// The magic effect for bleeding, which will damage the character every second until the effect ends.
/// </summary>
public sealed class BleedingMagicEffect : MagicEffect
{
private readonly Timer _damageTimer;
private readonly float _damage;
/// <summary>
/// Initializes a new instance of the <see cref="BleedingMagicEffect"/> class.
/// </summary>
/// <param name="powerUp">The power up.</param>
/// <param name="definition">The definition.</param>
/// <param name="duration">The duration.</param>
/// <param name="attacker">The attacker.</param>
/// <param name="owner">The owner.</param>
/// <param name="damage">The bleeding damage.</param>
public BleedingMagicEffect(IElement powerUp, MagicEffectDefinition definition, TimeSpan duration, IAttacker attacker, IAttackable owner, float damage)
: base(powerUp, definition, duration)
{
this.Attacker = attacker;
this.Owner = owner;
this._damage = damage;
this._damageTimer = new Timer(1000);
this._damageTimer.Elapsed += this.OnDamageTimerElapsed;
this._damageTimer.Start();
}
/// <summary>
/// Gets the owner of the effect.
/// </summary>
public IAttackable Owner { get; }
/// <summary>
/// Gets the attacker which applied the effect.
/// </summary>
public IAttacker Attacker { get; }
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
this._damageTimer.Stop();
this._damageTimer.Dispose();
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnDamageTimerElapsed(object? sender, ElapsedEventArgs e)
{
try
{
if (!this.Owner.IsAlive || this.IsDisposed || this.IsDisposing || this._damage <= 0)
{
return;
}
await this.Owner.ApplyBleedingDamageAsync(this.Attacker, (uint)this._damage).ConfigureAwait(false);
}
catch (Exception ex)
{
(this.Owner as ILoggerOwner)?.Logger.LogError(ex, "Error when applying bleeding damage");
}
}
}

View File

@@ -0,0 +1,205 @@
// <copyright file="BucketAreaOfInterestManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using Nito.AsyncEx;
/// <summary>
/// A area of interest manager which works with a bucket map.
/// </summary>
internal class BucketAreaOfInterestManager : IAreaOfInterestManager
{
/// <summary>
/// Initializes a new instance of the <see cref="BucketAreaOfInterestManager" /> class.
/// </summary>
/// <param name="chunkSize">Size of the chunk.</param>
public BucketAreaOfInterestManager(int chunkSize)
{
this.Map = new BucketMap<ILocateable>(0x100, true, chunkSize / 2, chunkSize);
}
/// <summary>
/// Gets the underlying bucket map.
/// </summary>
protected BucketMap<ILocateable> Map { get; }
/// <inheritdoc/>
public async ValueTask AddObjectAsync(ILocateable obj)
{
var newBucket = this.Map[obj.Position];
if (obj is IHasBucketInformation bucketInfo)
{
bucketInfo.OldBucket = null;
bucketInfo.NewBucket = newBucket;
}
await newBucket.AddAsync(obj).ConfigureAwait(false);
if (obj is IBucketMapObserver observingPlayer)
{
await this.UpdateObservingBucketsAsync(obj.Position, observingPlayer).ConfigureAwait(false);
}
}
/// <inheritdoc/>
/// <remarks>
/// This needs to take into account, that the <see cref="Walker"/> might change the <see cref="ILocateable.Position"/>,
/// but not updating the buckets. So accessing the bucket of the current coordinates might not be the current bucket!.
/// </remarks>
public async ValueTask RemoveObjectAsync(ILocateable obj)
{
if (obj is IHasBucketInformation bucketInfo)
{
// we remove the object from all known buckets and set old/new bucket to null
var oldBucket = bucketInfo.OldBucket;
var newBucket = bucketInfo.NewBucket;
bucketInfo.OldBucket = newBucket;
bucketInfo.NewBucket = null;
newBucket?.RemoveAsync(obj);
bucketInfo.OldBucket = newBucket;
oldBucket?.RemoveAsync(obj);
bucketInfo.OldBucket = null;
}
else
{
await this.Map[obj.Position].RemoveAsync(obj).ConfigureAwait(false);
}
if (obj is IBucketMapObserver observingPlayer)
{
foreach (var bucket in observingPlayer.ObservingBuckets)
{
bucket.ItemAdded -= observingPlayer.LocateableAddedAsync;
bucket.ItemRemoved -= observingPlayer.LocateableRemovedAsync;
}
if (observingPlayer.ObservingBuckets.Any(b => b.Count > 0))
{
await observingPlayer.LocateablesOutOfScopeAsync(observingPlayer.ObservingBuckets.SelectMany(o => o)).ConfigureAwait(false);
}
observingPlayer.ObservingBuckets.Clear();
}
}
/// <inheritdoc/>
public async ValueTask MoveObjectAsync(ILocateable obj, Point target, AsyncLock moveLock, MoveType moveType)
{
var differentBucket = await this.MoveObjectOnMapAsync(obj, target, moveLock, moveType).ConfigureAwait(false);
if (obj is IObservable observable)
{
await observable.ForEachWorldObserverAsync<IObjectMovedPlugIn>(p => p.ObjectMovedAsync(obj, moveType), true).ConfigureAwait(false);
}
var observingPlayer = obj as IBucketMapObserver;
if (differentBucket && observingPlayer != null)
{
await this.UpdateObservingBucketsAsync(target, observingPlayer).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public IEnumerable<ILocateable> GetInRange(Point point, int range)
{
return this.Map.GetInRange(point, range);
}
/// <summary>
/// Updates the observing buckets, and notifies the <paramref name="player"/> about new objects which are in scope, and old objects which are out of scope.
/// </summary>
/// <param name="newPoint">The new coordinates on the map.</param>
/// <param name="player">The player.</param>
protected async ValueTask UpdateObservingBucketsAsync(Point newPoint, IBucketMapObserver player)
{
var curbuckets = this.Map.GetBucketsInRange(newPoint, player.InfoRange).ToList(); // All buckets in range
var oldbuckets = player.ObservingBuckets.Where(i => !curbuckets.Contains(i)).ToList(); // Buckets which are not meant to be observed anymore
var newbuckets = curbuckets.Where(i => !player.ObservingBuckets.Contains(i)).ToList(); // New buckets for observation
oldbuckets.ForEach(i =>
{
i.ItemAdded -= player.LocateableAddedAsync;
i.ItemRemoved -= player.LocateableRemovedAsync;
});
newbuckets.ForEach(i =>
{
i.ItemAdded += player.LocateableAddedAsync;
i.ItemRemoved += player.LocateableRemovedAsync;
});
oldbuckets.ForEach(b => player.ObservingBuckets.Remove(b));
newbuckets.ForEach(player.ObservingBuckets.Add);
if (oldbuckets.Any(b => b.Count > 0))
{
await player.LocateablesOutOfScopeAsync(oldbuckets.SelectMany(o => o)).ConfigureAwait(false);
}
if (newbuckets.Any(b => b.Count > 0))
{
await player.NewLocateablesInScopeAsync(newbuckets.SelectMany(o => o)).ConfigureAwait(false);
}
}
private async ValueTask<bool> MoveObjectOnMapAsync(ILocateable obj, Point target, AsyncLock moveLock, MoveType moveType)
{
if (moveType == MoveType.Walk)
{
if (obj is ISupportWalk supportWalk)
{
target = supportWalk.WalkTarget;
}
}
var differentBucket = obj.Position.X / this.Map.BucketSideLength != target.X / this.Map.BucketSideLength
|| obj.Position.Y / this.Map.BucketSideLength != target.Y / this.Map.BucketSideLength;
if (!differentBucket)
{
if (moveType != MoveType.Walk)
{
obj.Position = target;
}
return false;
}
using (await moveLock.LockAsync())
{
var oldPosition = obj.Position;
Bucket<ILocateable>? oldBucket;
Bucket<ILocateable> newBucket = this.Map[target];
if (obj is IHasBucketInformation bucketInfo)
{
oldBucket = bucketInfo.NewBucket;
bucketInfo.NewBucket = newBucket;
bucketInfo.OldBucket = oldBucket;
}
else
{
oldBucket = this.Map[oldPosition];
}
// only set x and y of the object if it's not walking - the Walker sets these coordinates!
if (moveType != MoveType.Walk)
{
obj.Position = target;
}
if (oldBucket is not null)
{
await oldBucket.RemoveAsync(obj).ConfigureAwait(false);
}
await newBucket.AddAsync(obj).ConfigureAwait(false);
}
return true;
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="BucketItemEventArgs.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Event args which includes the involved bucket item.
/// </summary>
/// <typeparam name="T">The type of the bucket item.</typeparam>
public class BucketItemEventArgs<T> : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="BucketItemEventArgs{T}"/> class.
/// </summary>
/// <param name="item">The bucket item.</param>
public BucketItemEventArgs(T item)
{
this.Item = item;
}
/// <summary>
/// Gets the bucket item which is involved in the event.
/// </summary>
public T Item { get; }
}

View File

@@ -0,0 +1,129 @@
// <copyright file="BucketMap{T}.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// A two-dimensional map of buckets.
/// </summary>
/// <typeparam name="T">The type of objects which should be hold.</typeparam>
internal class BucketMap<T>
{
private const int DefaultListCapacity = 4;
private readonly Bucket<T>[] _list;
/// <summary>
/// Initializes a new instance of the <see cref="BucketMap{T}"/> class.
/// </summary>
public BucketMap()
{
this.BucketSideLength = 4;
this._list = new Bucket<T>[0x100 / this.BucketSideLength * 0x100 / this.BucketSideLength];
this.InitList(DefaultListCapacity);
}
/// <summary>
/// Initializes a new instance of the <see cref="BucketMap{T}"/> class.
/// </summary>
/// <param name="sideLength">Length of the side.</param>
/// <param name="createLists">if set to <c>true</c> the all lists are created now, and not by demand.</param>
/// <param name="listCapacity">The list capacity.</param>
/// <param name="bucketSideLength">Length of the bucket side.</param>
/// <exception cref="System.ArgumentException">SideLength must be a multiple of BucketSideLength.</exception>
public BucketMap(int sideLength, bool createLists, int listCapacity, int bucketSideLength)
{
if (sideLength % bucketSideLength != 0)
{
throw new ArgumentException($"SideLength ({sideLength}) must be a multiple of BucketSideLength ({bucketSideLength}).");
}
this.BucketSideLength = bucketSideLength;
this.SideLength = sideLength / bucketSideLength;
this._list = new Bucket<T>[this.SideLength * this.SideLength];
if (createLists)
{
this.InitList(listCapacity);
}
}
/// <summary>
/// Gets or sets the length of a side of the map. This value should be a multiple of <see cref="BucketSideLength"/>.
/// </summary>
public int SideLength { get; set; }
/// <summary>
/// Gets or sets the length of the bucket side.
/// </summary>
/// <value>
/// The length of the bucket side.
/// </value>
public int BucketSideLength { get; set; }
/// <summary>
/// Gets or sets the <see cref="Bucket{T}"/> at the specified coordinates.
/// </summary>
/// <value>
/// The <see cref="Bucket{T}"/>.
/// </value>
/// <param name="point">The coordinates.</param>
/// <returns>The <see cref="Bucket{T}"/> at the specified coordinates.</returns>
public Bucket<T> this[Point point]
{
get => this._list[this.GetListIndex(point)];
set => this._list[this.GetListIndex(point)] = value;
}
/// <summary>
/// Gets the items which are in range of the specified coordinates and range.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The maximum range.</param>
/// <returns>The items which are in range of the specified coordinate and range.</returns>
public IEnumerable<ILocateable> GetInRange(Point point, int range)
{
var result = new List<ILocateable>();
var buckets = this.GetBucketsInRange(point, range);
foreach (var bucket in buckets)
{
result.AddRange(bucket.OfType<ILocateable>().Where(obj => obj.IsInRange(point, range)));
}
return result;
}
/// <summary>
/// Gets the buckets in the specified range of the specified coordinates.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>The buckets in the specified range of the specified coordinate.</returns>
public IEnumerable<Bucket<T>> GetBucketsInRange(Point point, int range)
{
int maxX = Math.Min(point.X + range, (this.SideLength - 1) * this.BucketSideLength) / this.BucketSideLength;
int maxY = Math.Min(point.Y + range, (this.SideLength - 1) * this.BucketSideLength) / this.BucketSideLength;
int minX = Math.Max(point.X - range, 0) / this.BucketSideLength;
int minY = Math.Max(point.Y - range, 0) / this.BucketSideLength;
for (int i = minX; maxX >= i; ++i)
{
for (int j = minY; maxY >= j; ++j)
{
yield return this._list[i + (j * this.SideLength)];
}
}
}
private int GetListIndex(Point point) => (point.X / this.BucketSideLength) + ((point.Y / this.BucketSideLength) * this.SideLength);
private void InitList(int listCapacity)
{
for (int i = 0; i < this._list.Length; ++i)
{
this._list[i] = new Bucket<T>(listCapacity);
}
}
}

142
src/GameLogic/Bucket{T}.cs Normal file
View File

@@ -0,0 +1,142 @@
// <copyright file="Bucket{T}.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
/// <summary>
/// A bucket, which can be observed for added and removed items.
/// </summary>
/// <typeparam name="T">The type which should be hold by this bucket.</typeparam>
public sealed class Bucket<T> : IEnumerable<T>
{
private readonly List<T> _innerList;
private readonly AsyncReaderWriterLock _locker = new();
/// <summary>
/// Initializes a new instance of the <see cref="Bucket{T}"/> class.
/// </summary>
/// <param name="capacity">The initial capacity.</param>
public Bucket(int capacity)
{
this._innerList = new List<T>(capacity);
}
/// <summary>
/// Occurs when an item has been added.
/// </summary>
public event AsyncEventHandler<T>? ItemAdded;
/// <summary>
/// Occurs when an item has been removed.
/// </summary>
public event AsyncEventHandler<T>? ItemRemoved;
/// <summary>
/// Gets the count.
/// </summary>
public int Count => this._innerList.Count;
/// <summary>
/// Adds the specified item.
/// </summary>
/// <param name="item">The item.</param>
public async ValueTask AddAsync(T item)
{
using (await this._locker.WriterLockAsync())
{
this._innerList.Add(item);
}
if (this.ItemAdded is { } eventHandler)
{
await eventHandler(item).ConfigureAwait(false);
}
}
/// <summary>
/// Removes the specified item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The success.</returns>
public async ValueTask<bool> RemoveAsync(T item)
{
bool result;
using (await this._locker.WriterLockAsync())
{
result = this._innerList.Remove(item);
}
if (result && this.ItemRemoved is { } eventHandler)
{
await eventHandler(item).ConfigureAwait(false);
}
return result;
}
/// <inheritdoc/>
public IEnumerator<T> GetEnumerator()
{
return new LockingEnumerator<T>(this._locker, this._innerList);
}
/// <inheritdoc/>
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
private sealed class LockingEnumerator<TEnumerated> : IEnumerator<TEnumerated>
{
private readonly AsyncReaderWriterLock _locker;
private readonly IEnumerable<TEnumerated> _enumerable;
private IEnumerator<TEnumerated>? _enumerator;
private IDisposable? _lockRelease;
public LockingEnumerator(AsyncReaderWriterLock locker, IEnumerable<TEnumerated> enumerable)
{
this._locker = locker;
this._enumerable = enumerable;
}
public TEnumerated Current => this.Enumerator.Current;
object IEnumerator.Current => this.Enumerator.Current!;
private IEnumerator<TEnumerated> Enumerator
{
get
{
if (this._enumerator is { })
{
return this._enumerator;
}
this._lockRelease = this._locker.ReaderLock();
return this._enumerator ??= this._enumerable.GetEnumerator();
}
}
public bool MoveNext()
{
return this.Enumerator.MoveNext();
}
public void Reset()
{
this.Enumerator.Reset();
}
public void Dispose()
{
this._enumerator?.Dispose();
this._lockRelease?.Dispose();
}
}
}

View File

@@ -0,0 +1,155 @@
// <copyright file="CharacterExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel.Configuration.Quests;
using MUnique.OpenMU.GameLogic.Attributes;
using Nito.Disposables.Internals;
/// <summary>
/// Extensions for <see cref="Character"/>.
/// </summary>
public static class CharacterExtensions
{
private const int MaxLevel = 400;
private static readonly ushort[] FruitPointsPerLevel = GetFruitPoints(400).ToArray();
private static readonly ushort[] FruitPointsPerLevelMagicGladiator = GetFruitPoints(700).ToArray();
private static readonly ushort[] FruitPointsPerLevelDarkLord = GetFruitPoints(500).ToArray();
/// <summary>
/// Gets the maximum fruit points of the character.
/// </summary>
/// <param name="character">The character.</param>
/// <returns>The maximum fruit points of the character.</returns>
public static ushort GetMaximumFruitPoints(this Character character)
{
var index = (int)character.Attributes.First(a => a.Definition == Stats.Level).Value - 1;
return character.CharacterClass?.FruitCalculation switch
{
FruitCalculationStrategy.DarkLord => FruitPointsPerLevelDarkLord[index],
FruitCalculationStrategy.MagicGladiator => FruitPointsPerLevelMagicGladiator[index],
_ => FruitPointsPerLevel[index],
};
}
/// <summary>
/// Determines whether the character has a full ancient set equipped.
/// </summary>
/// <param name="character">The character.</param>
/// <returns>
/// <c>true</c> if the character has a full ancient set equipped; otherwise, <c>false</c>.
/// </returns>
public static bool HasFullAncientSetEquipped(this Character character)
{
if (character?.Inventory is null)
{
return false;
}
var equippedAncientSetItems = character.Inventory.Items.Where(i =>
i.ItemSlot <= InventoryConstants.LastEquippableItemSlotIndex
&& i.ItemSlot >= InventoryConstants.FirstEquippableItemSlotIndex
&& i.ItemSetGroups.Any(group => group.AncientSetDiscriminator > 0))
.Select(i =>
new
{
Item = i.Definition,
Set = i.ItemSetGroups.First(s => s.AncientSetDiscriminator > 0),
});
var ancientSets = equippedAncientSetItems.Select(i => i.Set.ItemSetGroup).WhereNotNull().Distinct();
return ancientSets.Any(set =>
set.Items.All(setItem => equippedAncientSetItems.Any(i =>
object.Equals(i.Item, setItem.ItemDefinition)
&& object.Equals(i.Set.ItemSetGroup, set))));
}
/// <summary>
/// Determines whether the character is able to increase StatAttributes.
/// </summary>
/// <param name="character">The character.</param>
/// <returns>
/// <c>true</c> if the character can increase StatAttributes; otherwise, <c>false</c>.
/// </returns>
public static bool CanIncreaseStats(this Character character)
{
return character.CharacterStatus == CharacterStatus.GameMaster || character.LevelUpPoints > 0;
}
/// <summary>
/// Determines whether the character is able to increase StatAttributes.
/// </summary>
/// <param name="character">The character.</param>
/// <param name="amount">The amount of points which should be added.</param>
/// <returns>
/// <c>true</c> if the character can increase StatAttributes; otherwise, <c>false</c>.
/// </returns>
public static bool CanIncreaseStats(this Character character, ushort amount)
{
return character.CharacterStatus == CharacterStatus.GameMaster || character.LevelUpPoints >= amount;
}
/// <summary>
/// Determines whether the character is a "special" character with reduced level requirements for maps and events.
/// Special characters have a <see cref="CharacterClass.LevelWarpRequirementReductionPercent"/> of 33.
/// Usually, this includes the classes Magic Gladiator, Dark Lord, Rage Fighter and Summoner.
/// </summary>
/// <param name="character">The character.</param>
/// <returns>
/// <c>true</c> if the character is a "special" character with reduced level requirements for maps and events; otherwise, <c>false</c>.
/// </returns>
public static bool IsSpecialCharacter(this Character character)
{
return character.CharacterClass?.LevelWarpRequirementReductionPercent > 30;
}
/// <summary>
/// Gets the effective move level requirement by considering <see cref="CharacterClass.LevelWarpRequirementReductionPercent"/>.
/// </summary>
/// <param name="character">The character.</param>
/// <param name="levelRequirement">The level requirement.</param>
/// <returns>The effective move level requirement.</returns>
public static int GetEffectiveMoveLevelRequirement(this Character character, int levelRequirement)
{
if (levelRequirement == 400)
{
return levelRequirement;
}
if (character.CharacterClass?.LevelWarpRequirementReductionPercent is { } reduction and > 0)
{
levelRequirement = levelRequirement * (100 - reduction) / 100;
}
return levelRequirement;
}
/// <summary>
/// Gets the quest drop item groups of a character.
/// </summary>
/// <param name="character">The character.</param>
/// <returns>The quest drop item groups of a character.</returns>
public static IEnumerable<DropItemGroup> GetQuestDropItemGroups(this Character character)
{
return character.QuestStates?
.SelectMany(q => q.ActiveQuest?.RequiredItems ?? Enumerable.Empty<QuestItemRequirement>())
.Select(i => i.DropItemGroup)
.WhereNotNull()
?? Enumerable.Empty<DropItemGroup>();
}
private static IEnumerable<ushort> GetFruitPoints(int divisor)
{
var current = 2;
for (int i = 0; i < MaxLevel; i++)
{
if (((i + 1) % 10) == 0)
{
current += (3 * (i + 11) / divisor) + 2;
}
yield return (ushort)current;
}
}
}

View File

@@ -0,0 +1,29 @@
// <copyright file="ComboState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Defines a state in the <see cref="ComboStateMachine"/>.
/// It includes data about the <see cref="RequiredSkill"/> to achieve this state.
/// </summary>
/// <seealso cref="MUnique.OpenMU.GameLogic.State" />
public class ComboState : State
{
/// <summary>
/// Initializes a new instance of the <see cref="ComboState"/> class.
/// </summary>
/// <param name="id">The identifier.</param>
/// <param name="requiredSkill">The required skill.</param>
public ComboState(Guid id, Skill? requiredSkill)
: base(id)
{
this.RequiredSkill = requiredSkill;
}
/// <summary>
/// Gets the required skill to achieve this state.
/// </summary>
public Skill? RequiredSkill { get; }
}

View File

@@ -0,0 +1,152 @@
// <copyright file="ComboStateMachine.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using System.Diagnostics;
/// <summary>
/// A state machine which is dynamically built, based on a <see cref="SkillComboDefinition"/>.
/// For creation, use the factory method <see cref="Create"/>.
/// </summary>
public sealed class ComboStateMachine : StateMachine
{
private static readonly ConcurrentDictionary<SkillComboDefinition, (ComboState Inital, ComboState Final)> StateCache = new();
private readonly TimeSpan _maximumCompletionTime;
private DateTime _lastStartedCombo;
/// <summary>
/// Initializes a new instance of the <see cref="ComboStateMachine"/> class.
/// </summary>
/// <param name="initial">The initial state.</param>
/// <param name="final">The final state.</param>
/// <param name="maximumCompletionTime">The maximum completion time for combos.</param>
private ComboStateMachine(ComboState initial, ComboState final, TimeSpan maximumCompletionTime)
: base(initial)
{
this._maximumCompletionTime = maximumCompletionTime;
this.InitialState = initial;
this.FinalState = final;
}
/// <summary>
/// Gets or sets the final state.
/// When this state is achieved, the state machine will proceed to the <see cref="InitialState"/>
/// to handle the next combo attempt.
/// </summary>
public ComboState FinalState { get; set; }
/// <summary>
/// Gets or sets the initial state.
/// </summary>
public ComboState InitialState { get; set; }
/// <summary>
/// Creates the specified combo definition, based on the <see cref="SkillComboDefinition"/>.
/// </summary>
/// <param name="comboDefinition">The combo definition.</param>
/// <returns>The created <see cref="ComboStateMachine"/>.</returns>.
public static ComboStateMachine Create(SkillComboDefinition comboDefinition)
{
var states = GetOrCreateStates(comboDefinition);
return new ComboStateMachine(states.Initial, states.Final, comboDefinition.MaximumCompletionTime);
}
/// <summary>
/// Registers the skill to trigger potential state advancements.
/// </summary>
/// <param name="skill">The performed skill.</param>
/// <returns><see langword="true"/>, if the combo completed; otherwise, <see langword="false"/>.</returns>
public async ValueTask<bool> RegisterSkillAsync(Skill skill)
{
if (DateTime.UtcNow - this._lastStartedCombo > this._maximumCompletionTime)
{
// If it took to long, reset to initial state.
await this.TryAdvanceToAsync(this.InitialState).ConfigureAwait(false);
}
var nextPossibleSkillState = this.CurrentState?.PossibleTransitions?.OfType<ComboState>().FirstOrDefault(t => t.RequiredSkill == skill.GetBaseSkill());
if (nextPossibleSkillState is null)
{
// If it's the wrong skill, reset to initial state.
await this.TryAdvanceToAsync(this.InitialState).ConfigureAwait(false);
return false;
}
if (this.CurrentState == this.InitialState)
{
this._lastStartedCombo = DateTime.UtcNow;
}
await this.TryAdvanceToAsync(nextPossibleSkillState).ConfigureAwait(false);
var canComplete = this.CurrentState?.PossibleTransitions?.Contains(this.FinalState) ?? false;
if (canComplete && await this.TryAdvanceToAsync(this.FinalState).ConfigureAwait(false))
{
await this.TryAdvanceToAsync(this.InitialState).ConfigureAwait(false);
return true;
}
return false;
}
private static (ComboState Initial, ComboState Final) GetOrCreateStates(SkillComboDefinition comboDefinition)
{
return StateCache.GetOrAdd(comboDefinition, BuildStates);
}
private static (ComboState Initial, ComboState Final) BuildStates(SkillComboDefinition comboDefinition)
{
var initialState = new ComboState(Guid.NewGuid(), null) { Name = "Initial", PossibleTransitions = new List<State>() };
var finalState = new ComboState(Guid.NewGuid(), null) { Name = "Finished", PossibleTransitions = new List<State> { initialState } };
var statesPerStep = new Dictionary<int, List<State>>();
foreach (var groupedSteps in comboDefinition.Steps.GroupBy(s => s.Order).OrderByDescending(s => s.Key))
{
foreach (var step in groupedSteps)
{
var stepState = new ComboState(Guid.NewGuid(), step.Skill);
stepState.Name = $"Step {step.Order}: {step.Skill?.Name}";
stepState.PossibleTransitions = new List<State>();
stepState.PossibleTransitions.Add(initialState);
if (step.Order == 1)
{
initialState.PossibleTransitions.Add(stepState);
}
if (step.IsFinalStep)
{
stepState.PossibleTransitions.Add(finalState);
}
else
{
if (statesPerStep.TryGetValue(step.Order + 1, out var nextSteps))
{
nextSteps
.OfType<ComboState>()
.Where(p => p.RequiredSkill != step.Skill)
.ForEach(stepState.PossibleTransitions.Add);
}
else
{
Debug.Fail("Inconsistent combo data");
}
}
if (!statesPerStep.TryGetValue(step.Order, out var list))
{
list = new List<State>();
statesPerStep[step.Order] = list;
}
list.Add(stepState);
}
}
return (initialState, finalState);
}
}

View File

@@ -0,0 +1,230 @@
// <copyright file="ConfigurationChangeMediator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A mediator which notifies about configuration changes for registered instances.
/// </summary>
public class ConfigurationChangeMediator : IConfigurationChangeMediator, IConfigurationChangeMediatorListener
{
private readonly ConcurrentDictionary<Guid, IChangeRegistration> _registrations = new();
private readonly ConcurrentDictionary<Type, ICreateRegistration> _createRegistrations = new();
/// <summary>
/// A non-generic interface for the change-registration.
/// </summary>
private interface IChangeRegistration : IDisposable
{
/// <summary>
/// Raises the <see cref="ChangeRegistration{TConfig}"/> event.
/// </summary>
ValueTask RaiseOnChangeAsync();
/// <summary>
/// Raises the <see cref="ChangeRegistration{TConfig}"/> event.
/// </summary>
ValueTask RaiseOnDeleteAsync();
}
/// <summary>
/// An non-generic interface for the <see cref="CreateRegistration{TConfig}"/>.
/// </summary>
/// <seealso cref="System.IDisposable" />
private interface ICreateRegistration : IDisposable
{
/// <summary>
/// Raises the <see cref="ChangeRegistration{TConfig}"/> event.
/// </summary>
/// <param name="created">The created configuration.</param>
ValueTask RaiseOnCreateAsync(object created);
}
/// <inheritdoc />
public IDisposable RegisterObject<TConfig, T>(TConfig config, T obj, Func<Action, TConfig, T, ValueTask>? onChange = null, Func<TConfig, T, ValueTask>? onDelete = null)
where T : class
where TConfig : class
{
var registration = (ChangeRegistration<TConfig>)this._registrations.AddOrUpdate(
config.GetId(),
_ => new ChangeRegistration<TConfig>(config),
(_, value) => value);
if (onChange is not null)
{
registration.OnChange += InvokeOnChangeAsync;
}
if (onDelete is not null)
{
registration.OnDelete += InvokeOnDeleteAsync;
}
var disposable = new Nito.Disposables.Disposable(() =>
{
if (onChange is not null)
{
registration.OnChange -= InvokeOnChangeAsync;
}
if (onDelete is not null)
{
registration.OnDelete -= InvokeOnDeleteAsync;
}
});
return disposable;
async ValueTask InvokeOnChangeAsync(TConfig changedConfig)
{
await onChange(
() =>
{
registration.OnChange -= InvokeOnChangeAsync;
if (onDelete is not null)
{
registration.OnDelete -= InvokeOnDeleteAsync;
}
},
changedConfig,
obj).ConfigureAwait(false);
}
async ValueTask InvokeOnDeleteAsync(TConfig changedConfig)
{
await onDelete(changedConfig, obj).ConfigureAwait(false);
}
}
/// <inheritdoc />
public IDisposable RegisterForNew<TConfig, T>(T obj, Func<TConfig, T, ValueTask> onNewConfig)
{
var registration = (CreateRegistration<TConfig>)this._createRegistrations.AddOrUpdate(
typeof(TConfig),
_ => new CreateRegistration<TConfig>(),
(_, value) => value);
registration.OnCreate += InvokeOnCreateAsync;
return registration;
async ValueTask InvokeOnCreateAsync(TConfig config)
{
await onNewConfig(config, obj).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async ValueTask HandleConfigurationChangedAsync(Type type, Guid id, object configuration)
{
if (this._registrations.TryGetValue(id, out var registration))
{
await registration.RaiseOnChangeAsync().ConfigureAwait(false);
}
}
/// <inheritdoc />
public async ValueTask HandleConfigurationAddedAsync(Type type, Guid id, object configuration)
{
if (this._createRegistrations.TryGetValue(type, out var createRegistration)
|| this._createRegistrations.TryGetValue(type.BaseType!, out createRegistration))
{
await createRegistration.RaiseOnCreateAsync(configuration).ConfigureAwait(false);
}
}
/// <inheritdoc />
public async ValueTask HandleConfigurationRemovedAsync(Type type, Guid id)
{
if (this._registrations.Remove(id, out var registration))
{
await registration.RaiseOnDeleteAsync().ConfigureAwait(false);
registration.Dispose();
}
}
/// <summary>
/// A registration for a change of a configuration.
/// </summary>
/// <typeparam name="TConfig">The type of the configuration.</typeparam>
private class ChangeRegistration<TConfig> : Disposable, IChangeRegistration
where TConfig : class
{
/// <summary>
/// Initializes a new instance of the <see cref="ChangeRegistration{TConfig}"/> class.
/// </summary>
/// <param name="config">The configuration.</param>
public ChangeRegistration(TConfig config)
{
this.Configuration = config;
}
/// <summary>
/// Occurs when a config has been changed.
/// </summary>
public event AsyncEventHandler<TConfig>? OnChange;
/// <summary>
/// Occurs when a config has been deleted.
/// </summary>
public event AsyncEventHandler<TConfig>? OnDelete;
/// <summary>
/// Gets the configuration in which the registration is interested in.
/// </summary>
private TConfig Configuration { get; }
/// <inheritdoc />
public async ValueTask RaiseOnChangeAsync()
{
await this.OnChange.SafeInvokeAsync(this.Configuration).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask RaiseOnDeleteAsync()
{
await this.OnDelete.SafeInvokeAsync(this.Configuration).ConfigureAwait(false);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
this.OnDelete = null;
this.OnChange = null;
}
}
/// <summary>
/// A registration for a creation of a configuration.
/// </summary>
/// <typeparam name="TConfig">The type of the configuration.</typeparam>
private class CreateRegistration<TConfig> : Disposable, ICreateRegistration
{
/// <summary>
/// Occurs when a new config of <typeparamref name="TConfig"/> is created.
/// </summary>
public event AsyncEventHandler<TConfig>? OnCreate;
/// <inheritdoc />
public async ValueTask RaiseOnCreateAsync(object created)
{
if (created is not TConfig typed)
{
throw new InvalidOperationException("created object is of wrong type");
}
await this.OnCreate.SafeInvokeAsync(typed).ConfigureAwait(false);
}
/// <inheritdoc />
protected override void Dispose(bool disposing)
{
base.Dispose(disposing);
this.OnCreate = null;
}
}
}

View File

@@ -0,0 +1,67 @@
// <copyright file="DamageAttributes.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The attributes of a damage.
/// </summary>
[Flags]
public enum DamageAttributes
{
/// <summary>
/// No defined attribute.
/// </summary>
Undefined = 0,
/// <summary>
/// The damage is critical (means 100% of the possible damage between minimum and maximum).
/// </summary>
Critical = 1,
/// <summary>
/// The damage is excellent (20% higher base damage than critical).
/// </summary>
Excellent = 2,
/// <summary>
/// The damage ignored the defense of the victim.
/// </summary>
IgnoreDefense = 4,
/// <summary>
/// The damage is caused by poison.
/// </summary>
Poison = 8,
/// <summary>
/// The damage was doubled.
/// </summary>
Double = 16,
/// <summary>
/// The damage was tripled (e.g. combo skill).
/// </summary>
Triple = 32,
/// <summary>
/// The damage was reflected.
/// </summary>
Reflected = 64,
/// <summary>
/// The damage includes the combo bonus.
/// </summary>
Combo = 128,
/// <summary>
/// The damage is a non-final hit of a quick sequence from a rage fighter skill.
/// </summary>
RageFighterStreakHit = 256,
/// <summary>
/// The damage is the final hit of a quick sequence from a rage fighter skill.
/// </summary>
RageFighterStreakFinalHit = 512,
}

View File

@@ -0,0 +1,593 @@
// <copyright file="DefaultDropGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using Nito.AsyncEx;
/// <summary>
/// The default drop generator.
/// </summary>
public class DefaultDropGenerator : IDropGenerator
{
/// <summary>
/// The amount of money which is dropped at least, and added to the gained experience.
/// </summary>
private const int BaseMoneyDrop = 7;
private const int DropLevelMaxGap = 12;
private const int SkillDropChancePercent = 50;
private const byte DefaultMaxItemOptionLevelDrop = 3;
private const byte MinItemOptionLevelDrop = 1;
private const byte MaxItemOptionLevelDrop = 4;
/// <summary>
/// A re-usable list of drop item groups.
/// </summary>
private readonly List<DropItemGroup> _chanceDropGroups = new(64);
private readonly List<DropItemGroup> _guaranteedDropGroups = new(16);
private readonly AsyncLock _lock = new();
private readonly IRandomizer _randomizer;
private readonly IList<ItemDefinition> _ancientItems;
private readonly IList<ItemDefinition> _droppableItems;
private readonly IList<ItemDefinition>?[] _droppableItemsPerMonsterLevel = new IList<ItemDefinition>?[byte.MaxValue + 1];
private readonly IList<ItemDefinition>?[] _droppableSocketItemsPerMonsterLevel = new IList<ItemDefinition>?[byte.MaxValue + 1];
private readonly byte _maxItemOptionLevelDrop;
private readonly byte _excellentItemDropLevelDelta;
/// <summary>
/// Initializes a new instance of the <see cref="DefaultDropGenerator" /> class.
/// </summary>
/// <param name="config">The configuration.</param>
/// <param name="randomizer">The randomizer.</param>
public DefaultDropGenerator(GameConfiguration config, IRandomizer randomizer)
{
this._excellentItemDropLevelDelta = config.ExcellentItemDropLevelDelta;
this._randomizer = randomizer;
this._maxItemOptionLevelDrop = IsValidOptionLevelDrop(config.MaximumItemOptionLevelDrop)
? config.MaximumItemOptionLevelDrop
: DefaultMaxItemOptionLevelDrop;
this._droppableItems = config.Items.Where(i => i.DropsFromMonsters).ToList();
this._ancientItems = this._droppableItems.Where(
i => i.PossibleItemSetGroups.Any(
g => g.Options?.PossibleOptions.Any(
o => object.Equals(o.OptionType, ItemOptionTypes.AncientOption)) ?? false))
.ToList();
}
/// <inheritdoc/>
public async ValueTask<(IEnumerable<Item> Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player)
{
var character = player.SelectedCharacter;
var map = player.CurrentMap?.Definition;
if (map is null || character is null)
{
return ([], null);
}
using var l = await this._lock.LockAsync();
this._guaranteedDropGroups.Clear();
this._chanceDropGroups.Clear();
if (monster.ObjectKind == NpcObjectKind.Destructible)
{
this.PartitionDropGroups(monster.DropItemGroups ?? []);
}
else
{
this.PartitionDropGroups(monster.DropItemGroups ?? []);
this.PartitionDropGroups(character.DropItemGroups ?? [], monster);
this.PartitionDropGroups(map.DropItemGroups ?? [], monster);
this.PartitionDropGroups(await GetQuestItemGroupsAsync(player).ConfigureAwait(false) ?? [], monster);
}
uint money = 0;
var (droppedItems, moneyResult) = this.GenerateDrops(monster, gainedExperience);
if (moneyResult > 0)
{
money = moneyResult;
}
this._guaranteedDropGroups.Clear();
this._chanceDropGroups.Clear();
return (droppedItems ?? Enumerable.Empty<Item>(), money > 0 ? money : null);
}
/// <inheritdoc/>
public Item? GenerateItemDrop(DropItemGroup selectedGroup)
{
return this.GenerateItemDrop(selectedGroup, selectedGroup.PossibleItems);
}
/// <inheritdoc/>
public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable<DropItemGroup> groups)
{
var group = this.SelectRandomGroup(groups.OrderBy(group => group.Chance), 1.0);
if (group is null)
{
return (null, null, ItemDropEffect.Undefined);
}
var dropEffect = ItemDropEffect.Undefined;
if (group is ItemDropItemGroup itemDropItemGroup)
{
dropEffect = itemDropItemGroup.DropEffect;
if (group.ItemType == SpecialItemType.Money)
{
return (null, (uint)itemDropItemGroup.MoneyAmount, dropEffect);
}
}
return (this.GenerateItemDrop(group), null, dropEffect);
}
/// <summary>
/// Gets a random item.
/// </summary>
/// <param name="monsterLevel">The monster level.</param>
/// <param name="isSocketItem">If set to <c>true</c>, it selects only socket items.</param>
/// <returns>A random item.</returns>
protected Item? GenerateRandomItem(int monsterLevel, bool isSocketItem)
{
var possible = this.GetPossibleList(monsterLevel, isSocketItem);
var item = this.GenerateRandomItem(possible);
if (item is null)
{
return null;
}
item.Level = GetItemLevelByMonsterLevel(item.Definition!, monsterLevel);
item.Durability = item.GetMaximumDurabilityOfOnePiece();
return item;
}
/// <summary>
/// Applies random options to the item.
/// </summary>
/// <param name="item">The item.</param>
protected void ApplyRandomOptions(Item item)
{
foreach (var option in item.Definition!.PossibleItemOptions.Where(o =>
o.AddsRandomly &&
!o.PossibleOptions.Any(po => object.Equals(po.OptionType, ItemOptionTypes.Excellent))))
{
this.ApplyOption(item, option);
}
if (item.Definition.MaximumSockets > 0)
{
item.SocketCount = this._randomizer.NextInt(1, item.Definition.MaximumSockets + 1);
}
if (item.CanHaveSkill())
{
item.HasSkill = this._randomizer.NextRandomBool(SkillDropChancePercent);
}
}
/// <summary>
/// Gets a random excellent item.
/// </summary>
/// <param name="monsterLevel">The monster level, if it's a monster drop.</param>
/// <param name="possibleItems">The possible items, if the drop is from an item box (e.g. box of kundun).</param>
/// <returns>A random excellent item.</returns>
protected Item? GenerateRandomExcellentItem(int monsterLevel = 0, ICollection<ItemDefinition>? possibleItems = null)
{
if (monsterLevel < this._excellentItemDropLevelDelta && possibleItems is null)
{
return null;
}
var possible = possibleItems ?? this.GetPossibleList(monsterLevel - this._excellentItemDropLevelDelta);
var item = this.GenerateRandomItem(possible);
if (item is null)
{
return null;
}
item.HasSkill = item.CanHaveSkill(); // every excellent item got skill
this.AddRandomExcOptions(item);
item.Durability = item.GetMaximumDurabilityOfOnePiece();
return item;
}
/// <summary>
/// Gets a random ancient item.
/// </summary>
/// <returns>A random ancient item.</returns>
protected Item? GenerateRandomAncient()
{
var item = this.GenerateRandomItem(this._ancientItems);
if (item is null)
{
return null;
}
item.HasSkill = item.CanHaveSkill(); // every ancient item got skill
this.ApplyRandomAncientOption(item);
item.Durability = item.GetMaximumDurabilityOfOnePiece();
return item;
}
private static byte GetItemLevelByMonsterLevel(ItemDefinition itemDefinition, int monsterLevel)
{
return Math.Min((byte)((monsterLevel - itemDefinition.DropLevel) / 3), itemDefinition.MaximumItemLevel);
}
private static async ValueTask<IEnumerable<DropItemGroup>> GetQuestItemGroupsAsync(Player player)
{
if (player.SelectedCharacter is not { } character)
{
return [];
}
if (player.Party is { } party)
{
return await party.GetQuestDropItemGroupsAsync(player).ConfigureAwait(false);
}
return character.GetQuestDropItemGroups();
}
private static bool IsGroupRelevant(MonsterDefinition monsterDefinition, DropItemGroup group)
{
if (group.MinimumMonsterLevel.HasValue && monsterDefinition[Stats.Level] < group.MinimumMonsterLevel)
{
return false;
}
if (group.MaximumMonsterLevel.HasValue && monsterDefinition[Stats.Level] > group.MaximumMonsterLevel)
{
return false;
}
if (group.Monster is { } monster && !monster.Equals(monsterDefinition))
{
return false;
}
return true;
}
private static bool IsValidOptionLevelDrop(byte value)
=> value is >= MinItemOptionLevelDrop and <= MaxItemOptionLevelDrop;
private static bool CanDropAtMonsterLevel(ItemDefinition itemDefinition, int monsterLevel)
{
if (itemDefinition.DropLevel > monsterLevel)
{
return false;
}
return itemDefinition.MaximumDropLevel is not { } maxDropLevel || monsterLevel <= maxDropLevel;
}
private (IList<Item>? Items, uint Money) GenerateDrops(MonsterDefinition monster, int gainedExperience)
{
uint money = 0;
List<Item>? droppedItems = null;
var remainingDrops = monster.NumberOfMaximumItemDrops;
// Guaranteed groups.
foreach (var group in this._guaranteedDropGroups)
{
if (remainingDrops <= 0)
{
break;
}
var item = this.GenerateItemDropOrMoney(monster, group, gainedExperience, out var droppedMoney);
if (item is not null)
{
droppedItems ??= new List<Item>(monster.NumberOfMaximumItemDrops);
droppedItems.Add(item);
}
if (droppedMoney is not null)
{
money += droppedMoney.Value;
}
remainingDrops--;
}
// Chance based groups.
if (remainingDrops > 0 && this._chanceDropGroups.Count > 0)
{
double totalChance = 0;
foreach (var group in this._chanceDropGroups)
{
totalChance += group.Chance;
}
for (int i = 0; i < remainingDrops; i++)
{
var group = this.SelectRandomGroup(this._chanceDropGroups, totalChance);
if (group is null)
{
continue;
}
var item = this.GenerateItemDropOrMoney(monster, group, gainedExperience, out var droppedMoney);
if (item is not null)
{
droppedItems ??= new List<Item>(monster.NumberOfMaximumItemDrops);
droppedItems.Add(item);
}
if (droppedMoney is not null)
{
money += droppedMoney.Value;
}
}
}
return (droppedItems, money);
}
private void PartitionDropGroups(IEnumerable<DropItemGroup> groups, MonsterDefinition? monster = null)
{
foreach (var group in groups)
{
if (monster is not null && !IsGroupRelevant(monster, group))
{
continue;
}
if (group.Chance >= 1.0)
{
this._guaranteedDropGroups.Add(group);
}
else
{
this._chanceDropGroups.Add(group);
}
}
}
private Item? GenerateItemDrop(DropItemGroup selectedGroup, ICollection<ItemDefinition> possibleItems)
{
var item = selectedGroup.ItemType switch
{
SpecialItemType.Ancient => this.GenerateRandomAncient(),
SpecialItemType.Excellent => this.GenerateRandomExcellentItem(possibleItems: possibleItems),
_ => this.GenerateRandomItem(possibleItems),
};
if (item is null)
{
return null;
}
if (item.Durability == 0)
{
item.Durability = item.GetMaximumDurabilityOfOnePiece();
}
if (selectedGroup is ItemDropItemGroup itemDropItemGroup)
{
item.Level = (byte)this._randomizer.NextInt(itemDropItemGroup.MinimumLevel, itemDropItemGroup.MaximumLevel + 1);
}
else if (selectedGroup.ItemLevel is { } itemLevel)
{
item.Level = itemLevel;
}
else
{
// no level defined, so it stays at 0.
}
item.Level = Math.Min(item.Level, item.Definition!.MaximumItemLevel);
return item;
}
private void ApplyOption(Item item, ItemOptionDefinition option)
{
for (int i = 0; i < option.MaximumOptionsPerItem; i++)
{
if (this._randomizer.NextRandomBool(option.AddChance))
{
var remainingOptions = option.PossibleOptions.Where(possibleOption => item.ItemOptions.All(link => link.ItemOption != possibleOption));
var newOption = remainingOptions.SelectRandom(this._randomizer);
if (newOption is null)
{
break;
}
var itemOptionLink = new ItemOptionLink
{
ItemOption = newOption,
Level = newOption.LevelDependentOptions
.Select(ldo => ldo.Level)
.Concat(newOption.LevelDependentOptions.Count > 0 ? [1] : []) // For base def/dmg opts level 1 is not an ItemOptionOfLevel entry
.Distinct()
.Where(l => l <= this._maxItemOptionLevelDrop)
.DefaultIfEmpty(0)
.SelectRandom(),
};
item.ItemOptions.Add(itemOptionLink);
}
}
}
private Item? GenerateRandomItem(ICollection<ItemDefinition>? possibleItems)
{
if (possibleItems is null || possibleItems.Count == 0)
{
return null;
}
var item = new TemporaryItem
{
Definition = possibleItems.ElementAt(this._randomizer.NextInt(0, possibleItems.Count)),
};
this.ApplyRandomOptions(item);
return item;
}
private void ApplyRandomAncientOption(Item item)
{
var ancientSet = item.Definition?.PossibleItemSetGroups
.Where(g => g!.Options?.PossibleOptions.Any(o => object.Equals(o.OptionType, ItemOptionTypes.AncientOption)) ?? false)
.SelectRandom(this._randomizer);
if (ancientSet is null)
{
return;
}
var itemOfSet = ancientSet.Items.First(i => object.Equals(i.ItemDefinition, item.Definition));
item.ItemSetGroups.Add(itemOfSet);
// For example: +5str or +10str.
if (itemOfSet.BonusOption is { } bonusOption)
{
var bonusOptionLink = new ItemOptionLink();
bonusOptionLink.ItemOption = bonusOption;
bonusOptionLink.Level = bonusOption.LevelDependentOptions.Select(o => o.Level).SelectRandom();
item.ItemOptions.Add(bonusOptionLink);
}
}
private void AddRandomExcOptions(Item item)
{
var excellentOptions = item.Definition!.PossibleItemOptions.FirstOrDefault(
o => o.PossibleOptions.Any(p => object.Equals(p.OptionType, ItemOptionTypes.Excellent)));
if (excellentOptions is null)
{
return;
}
var existingOptionCount = item.ItemOptions.Count(o => object.Equals(o.ItemOption?.OptionType, ItemOptionTypes.Excellent));
for (int i = existingOptionCount; i < excellentOptions.MaximumOptionsPerItem; i++)
{
if (i == 0)
{
// The first option is always added without a chance
var newOption = excellentOptions.PossibleOptions.SelectRandom(this._randomizer);
if (newOption is not null)
{
item.ItemOptions.Add(new ItemOptionLink { ItemOption = newOption });
existingOptionCount++;
}
continue;
}
if (this._randomizer.NextRandomBool(excellentOptions.AddChance))
{
var newOption = excellentOptions.PossibleOptions.SelectRandom(this._randomizer);
while (item.ItemOptions.Any(o => object.Equals(o.ItemOption, newOption)))
{
newOption = excellentOptions.PossibleOptions.SelectRandom(this._randomizer);
}
if (newOption is not null)
{
item.ItemOptions.Add(new ItemOptionLink { ItemOption = newOption });
}
}
}
}
private Item? GenerateItemDropOrMoney(MonsterDefinition monster, DropItemGroup selectedGroup, int gainedExperience, out uint? droppedMoney)
{
droppedMoney = null;
if (selectedGroup.PossibleItems?.Count > 0)
{
return this.GenerateItemFromGroup(monster, selectedGroup);
}
var item = this.GenerateSpecialItem(monster, selectedGroup);
if (item is null && selectedGroup.ItemType == SpecialItemType.Money)
{
droppedMoney = (uint)(gainedExperience + BaseMoneyDrop);
}
return item;
}
private Item? GenerateItemFromGroup(MonsterDefinition monster, DropItemGroup selectedGroup)
{
var isDropSpecificForMonster = monster.DropItemGroups.Contains(selectedGroup);
if (isDropSpecificForMonster)
{
return this.GenerateItemDrop(selectedGroup, selectedGroup.PossibleItems!);
}
var monsterLevel = (int)monster[Stats.Level];
var isJewel = selectedGroup.ItemType == SpecialItemType.Jewel;
var filteredPossibleItems = selectedGroup.PossibleItems!
.Where(it => CanDropAtMonsterLevel(it, monsterLevel)
&& (isJewel || it.DropLevel == 0 || it.DropLevel > monsterLevel - DropLevelMaxGap))
.ToList();
return this.GenerateItemDrop(selectedGroup, filteredPossibleItems);
}
private Item? GenerateSpecialItem(MonsterDefinition monster, DropItemGroup selectedGroup)
{
var monsterLevel = (int)monster[Stats.Level];
return selectedGroup.ItemType switch
{
SpecialItemType.Ancient => this.GenerateRandomAncient(),
SpecialItemType.Excellent => this.GenerateRandomExcellentItem(monsterLevel),
SpecialItemType.RandomItem => this.GenerateRandomItem(monsterLevel, false),
SpecialItemType.SocketItem => this.GenerateRandomItem(monsterLevel, true),
_ => null,
};
}
private DropItemGroup? SelectRandomGroup(IEnumerable<DropItemGroup> groups, double totalChance)
{
var remainingThreshold = this._randomizer.NextDouble();
if (totalChance > 1.0)
{
remainingThreshold *= totalChance;
}
foreach (var group in groups)
{
if (remainingThreshold > group.Chance)
{
remainingThreshold -= group.Chance;
}
else
{
return group;
}
}
return null;
}
private IList<ItemDefinition>? GetPossibleList(int monsterLevel, bool isSocketItem = false)
{
if (monsterLevel is < byte.MinValue or > byte.MaxValue)
{
return null;
}
var cache = isSocketItem ? this._droppableSocketItemsPerMonsterLevel : this._droppableItemsPerMonsterLevel;
return cache[monsterLevel]
??= (from it in this._droppableItems
where CanDropAtMonsterLevel(it, monsterLevel)
&& (it.DropLevel > monsterLevel - DropLevelMaxGap)
&& (!isSocketItem || it.MaximumSockets > 0)
select it).ToList();
}
}

View File

@@ -0,0 +1,70 @@
// <copyright file="DirectionExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Extension methods which have to do with handling <see cref="Direction"/>s.
/// </summary>
public static class DirectionExtensions
{
/// <summary>
/// Calculates the target point based on the origin and the specified direction.
/// </summary>
/// <param name="origin">From point.</param>
/// <param name="direction">The direction.</param>
/// <returns>The target point based on the origin and the specified direction.</returns>
/// <exception cref="ArgumentException">direction.</exception>
public static Point CalculateTargetPoint(this Point origin, Direction direction)
{
return direction switch
{
Direction.Undefined => origin,
Direction.North => new Point((byte)(origin.X - 1), (byte)(origin.Y + 1)),
Direction.South => new Point((byte)(origin.X + 1), (byte)(origin.Y - 1)),
Direction.East => new Point((byte)(origin.X + 1), (byte)(origin.Y + 1)),
Direction.West => new Point((byte)(origin.X - 1), (byte)(origin.Y - 1)),
Direction.NorthEast => new Point(origin.X, (byte)(origin.Y + 1)),
Direction.NorthWest => new Point((byte)(origin.X - 1), origin.Y),
Direction.SouthEast => new Point((byte)(origin.X + 1), origin.Y),
Direction.SouthWest => new Point(origin.X, (byte)(origin.Y - 1)),
_ => throw new ArgumentException($"Direction value {direction} is not defined", nameof(direction)),
};
}
/// <summary>
/// Gets the direction from one point to another.
/// </summary>
/// <param name="from">The origin point.</param>
/// <param name="to">The target point.</param>
/// <returns>The direction from the origin to the target.</returns>
public static Direction GetDirectionTo(this Point from, Point to)
{
if (from == to)
{
return Direction.Undefined;
}
double angle = Math.Atan2(to.Y - from.Y, to.X - from.X);
angle += Math.PI;
angle /= Math.PI / 4;
int halfQuarter = Convert.ToInt32(angle);
halfQuarter %= 8;
return halfQuarter switch
{
7 => Direction.North,
0 => Direction.NorthWest,
1 => Direction.West,
2 => Direction.SouthWest,
3 => Direction.South,
4 => Direction.SouthEast,
5 => Direction.East,
6 => Direction.NorthEast,
_ => Direction.Undefined,
};
}
}

View File

@@ -0,0 +1,66 @@
// <copyright file="Disposable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Base class for a disposable class.
/// </summary>
public class Disposable : IDisposable
{
/// <summary>
/// Finalizes an instance of the <see cref="Disposable"/> class.
/// </summary>
~Disposable()
{
this.Dispose(false);
}
/// <summary>
/// Gets a value indicating whether this instance is disposed.
/// </summary>
/// <value>
/// <c>true</c> if this instance is disposed; otherwise, <c>false</c>.
/// </value>
public bool IsDisposed { get; private set; }
/// <summary>
/// Gets a value indicating whether this instance is disposing.
/// </summary>
/// <value>
/// <c>true</c> if this instance is disposing; otherwise, <c>false</c>.
/// </value>
public bool IsDisposing { get; private set; }
/// <summary>
/// Performs application-defined tasks associated with freeing, releasing, or resetting unmanaged resources.
/// </summary>
public void Dispose()
{
if (!this.IsDisposed && !this.IsDisposing)
{
this.IsDisposing = true;
try
{
this.Dispose(true);
this.IsDisposed = true;
}
finally
{
this.IsDisposing = false;
}
}
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases unmanaged and - optionally - managed resources.
/// </summary>
/// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
}
}

View File

@@ -0,0 +1,298 @@
// <copyright file="DroppedItem.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.Pathfinding;
using Nito.AsyncEx;
/// <summary>
/// An item which got dropped on the ground of a map.
/// </summary>
public sealed class DroppedItem : AsyncDisposable, ILocateable
{
private static readonly TimeSpan TimeUntilDropIsFree = TimeSpan.FromSeconds(10);
/// <summary>
/// Gets the pickup lock. Used to synchronize pick up requests from the players.
/// </summary>
private readonly AsyncLock _pickupLock = new();
private readonly DateTime _dropTimestamp = DateTime.UtcNow;
/// <summary>
/// Indicates if the item was persistent (exists on the database) when it was dropped.
/// If it wasn't and we clean it up, then we don't need to delete it.
/// </summary>
private readonly bool _wasItemPersisted;
private Player? _dropper;
private IEnumerable<object>? _owners;
private Timer? _removeTimer;
private bool _availableToPick = true;
/// <summary>
/// Initializes a new instance of the <see cref="DroppedItem" /> class.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="position">The position where the item was dropped on the map.</param>
/// <param name="map">The map.</param>
/// <param name="dropper">The dropper.</param>
public DroppedItem(Item item, Point position, GameMap map, Player dropper)
: this(item, position, map, dropper, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="DroppedItem" /> class.
/// </summary>
/// <param name="item">The item, which should be detached from any player persistence context.</param>
/// <param name="position">The position where the item was dropped on the map.</param>
/// <param name="map">The map.</param>
/// <param name="dropper">The dropper.</param>
/// <param name="owners">The owners.</param>
/// <param name="wasItemPersisted">If set to <c>true</c>, the item was persisted before and exists on the database.</param>
public DroppedItem(Item item, Point position, GameMap map, Player? dropper, IEnumerable<object>? owners, bool wasItemPersisted = false)
{
this.Item = item;
this.Position = position;
this.CurrentMap = map;
this._dropper = dropper;
this._owners = owners;
this._wasItemPersisted = wasItemPersisted;
this._removeTimer = new Timer(this.DisposeAndDelete, null, (int)map.ItemDropDuration.TotalMilliseconds, Timeout.Infinite);
}
/// <summary>
/// Gets the item.
/// </summary>
public Item Item { get; }
/// <inheritdoc />
public Point Position { get; set; }
/// <summary>
/// Gets or sets the identifier.
/// </summary>
public ushort Id { get; set; }
/// <inheritdoc/>
public GameMap CurrentMap { get; }
/// <summary>
/// Tries to pick the item up by the specified player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>
/// The success.
/// StackTarget: If the success is <c>true</c>, and this is not <c>null</c>, this dropped item was stacked on an existing item of the players inventory.
/// </returns>
/// <remarks>
/// Can be overwritten, for example for quest items which dropped only for a specific player.
/// </remarks>
public async ValueTask<(bool Success, Item? StackTarget)> TryPickUpByAsync(Player player)
{
Item? stackTarget = null;
if (!this._availableToPick)
{
return (false, stackTarget);
}
if (this.Item.IsStackable())
{
stackTarget = player.Inventory?.Items.FirstOrDefault(i => i.CanCompletelyStackOn(this.Item));
}
if (stackTarget != null)
{
if (await this.TryStackOnItemAsync(player, stackTarget).ConfigureAwait(false))
{
return (true, stackTarget);
}
return (false, stackTarget);
}
if (this.Item.Definition!.IsBoundToCharacter && !this.IsPlayerAnOwner(player))
{
return (false, stackTarget);
}
if (!this.IsPlayerAnOwner(player)
&& DateTime.UtcNow < this._dropTimestamp.Add(TimeUntilDropIsFree))
{
return (false, stackTarget);
}
return (await this.TryPickUpAsync(player).ConfigureAwait(false), stackTarget);
}
/// <inheritdoc/>
public override string ToString()
{
return $"{this.Id}: {this.Item} at {this.CurrentMap.Definition.Name} ({this.Position})";
}
/// <inheritdoc/>
protected override async ValueTask DisposeAsyncCore()
{
var timer = this._removeTimer;
if (timer != null)
{
this._removeTimer = null;
await timer.DisposeAsync().ConfigureAwait(false);
await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false);
this._dropper = null;
this._owners = null;
}
await base.DisposeAsyncCore().ConfigureAwait(false);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void DisposeAndDelete(object? state)
{
var player = this._dropper;
try
{
await this.DisposeAsync().ConfigureAwait(false);
if (player != null)
{
await this.DeleteItemAsync(player).ConfigureAwait(false);
}
}
catch (Exception ex)
{
// we have to catch all errors, because it runs under a pooled thread without an additional safety net ;-)
player?.Logger.LogError(ex, "Error during DroppedItem.DisposeAndDeleteAsync");
}
}
/// <summary>
/// Gets a value indicating whether the owner-pickup priority period is still active.
/// </summary>
public bool IsOwnerPickupPriorityActive => DateTime.UtcNow < this._dropTimestamp.Add(TimeUntilDropIsFree);
/// <summary>
/// Determines whether the specified player is an owner of this dropped item.
/// </summary>
/// <param name="player">The player.</param>
/// <returns><c>true</c> if the player is an owner; otherwise, <c>false</c>.</returns>
public bool IsPlayerAnOwner(Player player)
{
return this._owners?.Contains(player) ?? true;
}
private async ValueTask<bool> TryPickUpAsync(Player player)
{
player.Logger.LogDebug("Player {0} tries to pick up {1}", player, this);
var slot = player.Inventory?.CheckInvSpace(this.Item);
if (!slot.HasValue || slot < InventoryConstants.LastEquippableItemSlotIndex)
{
player.Logger.LogDebug("Inventory full, Player {0}, Item {1}", player, this);
return false;
}
var itemWasTemporary = this.Item is TemporaryItem;
using (await this._pickupLock.LockAsync())
{
if (!this._availableToPick)
{
player.Logger.LogDebug("Picked up by another player in the mean time, Player {0}, Item {1}", player, this);
return false;
}
if (!itemWasTemporary)
{
// We already attach it here, so that the next changes are in the context.
player.PersistenceContext.Attach(this.Item);
}
if (!await player.Inventory!.AddItemAsync((byte)slot, this.Item).ConfigureAwait(false))
{
player.Logger.LogDebug("Item could not be added to the inventory, Player {0}, Item {1}", player, this);
if (!itemWasTemporary)
{
player.PersistenceContext.Detach(this.Item);
}
return false;
}
if (itemWasTemporary)
{
// We set the item slot in a temporary item manually, so the further logic can report to the client where the item has been added.
// If it's temporary, it has been converted into a persistent item before it was added to the inventory. So it's not the same instance.
// If it's not temporary, this step is not required, because the inventory already set the ItemSlot in the same instance we're holding here.
this.Item.ItemSlot = (byte)slot;
}
this._availableToPick = false;
}
player.Logger.LogDebug("Item '{0}' was picked up by player '{1}' and added to his inventory.", this, player);
await this.DisposeAsync().ConfigureAwait(false);
return true;
}
private async ValueTask<bool> TryStackOnItemAsync(Player player, Item stackTarget)
{
player.Logger.LogDebug("Player {0} tries to pick up {1}, trying to add to an existing item at slot {2}", player, this, stackTarget.ItemSlot);
using (await this._pickupLock.LockAsync())
{
if (!this._availableToPick)
{
player.Logger.LogDebug("Picked up by another player in the mean time, Player {0}, Item {1}", player, this);
return false;
}
stackTarget.Durability += this.Item.Durability;
this._availableToPick = false;
}
player.Logger.LogInformation("Item '{0}' got picked up by player '{1}'. Durability of available stack {2} increased to {3}", this, player, stackTarget, stackTarget.Durability);
this.DisposeAndDelete(null);
if (player.GameContext.PlugInManager.GetPlugInPoint<PlugIns.IItemStackedPlugIn>() is { } itemStackedPlugIn)
{
await itemStackedPlugIn.ItemStackedAsync(player, this.Item, stackTarget).ConfigureAwait(false);
}
return true;
}
private async ValueTask DeleteItemAsync(Player player)
{
player.Logger.LogDebug("Item '{0}' which was dropped by player '{1}' is getting deleted.", this, player);
if (!this._wasItemPersisted)
{
return;
}
try
{
var repositoryManager = player.GameContext.PersistenceContextProvider;
// We could use here the persistence context of the dropper - but if it logged out and is not saving anymore, the deletion would not be saved.
// So we use a new temporary persistence context instead.
// We use a trade-context as it just focuses on the items. Otherwise, we would track a lot more items.
using var context = repositoryManager.CreateNewTradeContext();
context.Attach(this.Item);
await context.DeleteAsync(this.Item).ConfigureAwait(false);
await context.SaveChangesAsync().ConfigureAwait(false);
}
catch (Exception e)
{
player.Logger.LogWarning("Exception during deleting of the item {0}: {1}\n{2}", this, e.Message, e.StackTrace);
}
player.GameContext.PlugInManager.GetPlugInPoint<IItemDestroyedPlugIn>()?.ItemDestroyed(this.Item);
}
}

View File

@@ -0,0 +1,173 @@
// <copyright file="DroppedMoney.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Diagnostics;
using System.Threading;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.Pathfinding;
using Nito.AsyncEx;
/// <summary>
/// Money which got dropped on the ground of a map.
/// </summary>
public sealed class DroppedMoney : AsyncDisposable, ILocateable
{
/// <summary>
/// Gets the pickup lock. Used to synchronize pick up requests from the players.
/// </summary>
private readonly AsyncLock _pickupLock;
private Timer? _removeTimer;
private bool _availableToPick = true;
/// <summary>
/// Initializes a new instance of the <see cref="DroppedMoney" /> class.
/// </summary>
/// <param name="amount">The amount.</param>
/// <param name="position">The position where the item was dropped on the map.</param>
/// <param name="map">The map.</param>
public DroppedMoney(uint amount, Point position, GameMap map)
{
this.Amount = amount;
this._pickupLock = new();
this.Position = position;
this.CurrentMap = map;
this._removeTimer = new Timer(this.OnTimerTimeout, null, (int)map.ItemDropDuration.TotalMilliseconds, Timeout.Infinite);
}
/// <summary>
/// Gets the money item.
/// </summary>
public uint Amount { get; }
/// <inheritdoc />
public Point Position { get; set; }
/// <summary>
/// Gets or sets the identifier.
/// </summary>
public ushort Id { get; set; }
/// <inheritdoc/>
public GameMap CurrentMap { get; }
/// <summary>
/// Tries to pick the money by the specified player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>
/// The success.
/// </returns>
/// <remarks>
/// Can be overwritten, for example for quest items which dropped only for a specific player.
/// </remarks>
public async ValueTask<bool> TryPickUpByAsync(Player player)
{
player.Logger.LogDebug("Player {0} tries to pick up {1}", player, this);
using (await this._pickupLock.LockAsync())
{
if (!this._availableToPick)
{
player.Logger.LogDebug("Picked up by another player in the mean time, Player {0}, Money {1}", player, this);
return false;
}
this._availableToPick = false;
}
if (player.Party is { } party)
{
var partyMembers = party.PartyList
.OfType<Player>()
.Where(p => p.CurrentMap == player.CurrentMap && !p.IsAtSafezone() && p.Attributes is { })
.ToList();
if (partyMembers.Count > 0)
{
var share = (int)(this.Amount / partyMembers.Count);
foreach (var member in partyMembers)
{
member.TryAddMoney((int)(share * member.Attributes![Stats.MoneyAmountRate]));
}
}
}
else
{
var clampMoneyOnPickup = player.GameContext?.Configuration?.ClampMoneyOnPickup ?? false;
if (clampMoneyOnPickup)
{
var maxMoney = player.GameContext?.Configuration?.MaximumInventoryMoney ?? int.MaxValue;
var currentMoney = player.Money;
var amountToAdd = (int)Math.Min(this.Amount, (uint)Math.Max(0, maxMoney - currentMoney));
if (amountToAdd <= 0)
{
player.Logger.LogDebug("Player is at maximum money limit, Player {0}, Money {1}", player, this);
return false;
}
if (!player.TryAddMoney(amountToAdd))
{
player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this);
return false;
}
}
else
{
if (!player.TryAddMoney((int)this.Amount))
{
player.Logger.LogDebug("Money could not be added to the inventory, Player {0}, Money {1}", player, this);
return false;
}
}
}
await this.DisposeAsync().ConfigureAwait(false);
return true;
}
/// <inheritdoc/>
public override string ToString()
{
return $"Money: {this.Amount} at {this.CurrentMap.Definition.Name} ({this.Position})";
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
if (this._removeTimer is { } timer)
{
try
{
this._removeTimer = null;
await timer.DisposeAsync().ConfigureAwait(false);
await this.CurrentMap.RemoveAsync(this).ConfigureAwait(false);
}
catch (Exception e)
{
Debug.Fail(e.Message, e.StackTrace);
}
}
await base.DisposeAsyncCore().ConfigureAwait(false);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnTimerTimeout(object? state)
{
try
{
await this.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
}

489
src/GameLogic/DuelRoom.cs Normal file
View File

@@ -0,0 +1,489 @@
// <copyright file="DuelRoom.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
using MUnique.OpenMU.GameLogic.Views.Duel;
using Nito.AsyncEx;
/// <summary>
/// The state of the duel.
/// </summary>
public enum DuelState
{
/// <summary>
/// Duel state is undefined.
/// </summary>
Undefined,
/// <summary>
/// A duel was requested.
/// </summary>
DuelRequested,
/// <summary>
/// Duel request was refused.
/// </summary>
DuelRefused,
/// <summary>
/// Duel failed to start.
/// </summary>
DuelStartFailed,
/// <summary>
/// Duel request was accepted.
/// </summary>
DuelAccepted,
/// <summary>
/// Duel has started.
/// </summary>
DuelStarted,
/// <summary>
/// Dual was cancelled.
/// </summary>
DuelCancelled,
/// <summary>
/// Duel has finished.
/// </summary>
DuelFinished,
}
/// <summary>
/// A class that manages a duel between two players.
/// </summary>
public sealed class DuelRoom : AsyncDisposable
{
private readonly AsyncLock _spectatorLock = new();
private CancellationTokenSource? _cts = new();
private byte _scoreRequester;
private byte _scoreOpponent;
private int _maximumScore;
private int _maximumSpectators;
/// <summary>
/// Initializes a new instance of the <see cref="DuelRoom" /> class.
/// </summary>
/// <param name="area">The duel area.</param>
/// <param name="requester">The requester.</param>
/// <param name="opponent">The opponent.</param>
public DuelRoom(DuelArea area, Player requester, Player opponent)
{
this.Area = area;
this.Index = area.Index;
this.Requester = requester;
this.Opponent = opponent;
this.CreatedAt = DateTime.UtcNow;
this._maximumScore = requester.GameContext.Configuration.DuelConfiguration?.MaximumScore ?? 10;
this._maximumSpectators = requester.GameContext.Configuration.DuelConfiguration?.MaximumSpectatorsPerDuelRoom ?? 10;
}
/// <summary>
/// Gets the area of the duel.
/// </summary>
public DuelArea Area { get; }
/// <summary>
/// Gets the index of the area of the duel.
/// </summary>
public int Index { get; }
/// <summary>
/// Gets or sets the <see cref="DateTime"/> of the start of the duel.
/// </summary>
public DateTime CreatedAt { get; set; }
/// <summary>
/// Gets the player that requested the duel.
/// </summary>
public Player Requester { get; }
/// <summary>
/// Gets the player that accepted the duel.
/// </summary>
public Player Opponent { get; }
/// <summary>
/// Gets or sets the score of the player that requested the duel.
/// </summary>
public byte ScoreRequester
{
get => this._scoreRequester;
set
{
this._scoreRequester = value;
if (value >= this._maximumScore)
{
this.State = DuelState.DuelFinished;
}
}
}
/// <summary>
/// Gets or sets the score of the player that accepted the duel.
/// </summary>
public byte ScoreOpponent
{
get => this._scoreOpponent;
set
{
this._scoreOpponent = value;
if (value >= this._maximumScore)
{
this.State = DuelState.DuelFinished;
}
}
}
// public bool IsAccepted { get; set; }
// public bool IsFinished { get; set; }
/// <summary>
/// Gets or sets the state of the duel.
/// </summary>
public DuelState State { get; set; }
/// <summary>
/// Gets a lock object used to update the score of the duel.
/// </summary>
public AsyncLock Lock { get; } = new();
/// <summary>
/// Gets the duel room spectators list.
/// </summary>
public List<Player> Spectators { get; } = new();
/// <summary>
/// Gets all the players taking part in the duel, either as duelists or spectators.
/// </summary>
public IEnumerable<Player> AllPlayers
{
get
{
yield return this.Requester;
yield return this.Opponent;
for (var index = this.Spectators.Count - 1; index >= 0; index--)
{
var spectator = this.Spectators[index];
yield return spectator;
}
}
}
/// <summary>
/// Gets a value indicating whether the duel room still has spectator slots.
/// </summary>
public bool IsOpen => this.Spectators.Count < this._maximumSpectators;
/// <summary>
/// Gets a value indicating whether the player is participating in the duel as a duelist.
/// </summary>
/// <param name="player">The player.</param>
public bool IsDuelist(Player player)
{
return this.Requester == player || this.Opponent == player;
}
/// <summary>
/// Removes the spectator from the room.
/// </summary>
/// <param name="spectator">The spectator which should be removed.</param>
public async ValueTask RemoveSpectatorAsync(Player spectator)
{
using (await this._spectatorLock.LockAsync())
{
if (!this.Spectators.Remove(spectator))
{
return;
}
}
for (int j = this.Spectators.Count; j >= 0; --j)
{
var player = this.Spectators[j];
await player.InvokeViewPlugInAsync<IDuelSpectatorRemovedPlugIn>(p => p.SpectatorRemovedAsync(spectator)).ConfigureAwait(false);
}
await spectator.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync()).ConfigureAwait(false);
await spectator.RemoveInvisibleEffectAsync().ConfigureAwait(false);
}
/// <summary>
/// Starts the duel.
/// </summary>
/// <returns>A <see cref="Task"/>.</returns>
/// <exception cref="InvalidOperationException">Occurs when one of the players has left the duel map.</exception>
public async Task RunDuelAsync()
{
try
{
var cancellationToken = this._cts?.Token ?? default;
// We first wait until both players are on the map
while (this.Requester.Id == default || this.Opponent.Id == default)
{
await Task.Delay(500, cancellationToken).ConfigureAwait(false);
}
if (this.Requester.CurrentMap?.Definition != this.Area.FirstPlayerGate?.Map
|| this.Opponent.CurrentMap?.Definition != this.Area.SecondPlayerGate?.Map)
{
throw new InvalidOperationException("Duel cannot start when any of the players left the duel map");
}
cancellationToken.ThrowIfCancellationRequested();
this.State = DuelState.DuelAccepted;
for (int i = 5; i > 0; i--)
{
var seconds = i;
await this.AllPlayers.ForEachAsync(p => p.ShowLocalizedGoldenMessageAsync(nameof(PlayerMessage.DuelBattleBeginsInSecondsFormat), seconds)).ConfigureAwait(false);
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
}
await this.Requester.InvokeViewPlugInAsync<IShowDuelRequestResultPlugIn>(p => p.ShowDuelRequestResultAsync(DuelStartResult.Success, this.Opponent)).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IShowDuelRequestResultPlugIn>(p => p.ShowDuelRequestResultAsync(DuelStartResult.Success, this.Requester)).ConfigureAwait(false);
await this.Requester.InvokeViewPlugInAsync<IShowDuelScoreUpdatePlugIn>(p => p.UpdateScoreAsync(this)).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IShowDuelScoreUpdatePlugIn>(p => p.UpdateScoreAsync(this)).ConfigureAwait(false);
await this.Requester.InvokeViewPlugInAsync<IInitializeDuelPlugIn>(p => p.InitializeDuelAsync(this)).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IInitializeDuelPlugIn>(p => p.InitializeDuelAsync(this)).ConfigureAwait(false);
this.State = DuelState.DuelStarted;
while (this.State is not (DuelState.DuelFinished or DuelState.DuelCancelled))
{
cancellationToken.ThrowIfCancellationRequested();
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
await this.SendCurrentStateToAllPlayersAsync().ConfigureAwait(false);
}
if (this.State is DuelState.DuelFinished)
{
await this.FinishDuelAsync().ConfigureAwait(false);
}
}
catch
{
if (this.State is not DuelState.DuelFinished)
{
this.State = DuelState.DuelCancelled;
await this.StopDuelAsync().ConfigureAwait(false);
}
}
finally
{
await this.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Cancels and stops the duel.
/// </summary>
/// <returns>A <see cref="ValueTask"/>.</returns>
public async ValueTask CancelDuelAsync()
{
if (this._cts is { } cts)
{
await cts.CancelAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Gets the spawn gate of the player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The gate where the player is teleported to.</returns>
public ExitGate? GetSpawnGate(Player player)
{
if (this.Opponent == player)
{
return this.Area.SecondPlayerGate;
}
if (this.Requester == player)
{
return this.Area.FirstPlayerGate;
}
return this.Area.SpectatorsGate;
}
/// <summary>
/// Tries to add the player as a spectator to the duel.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>A <see cref="ValueTask"/> with the result.</returns>
public async ValueTask<bool> TryAddSpectatorAsync(Player player)
{
if (this.Area.SpectatorsGate is not { } spectatorsGate)
{
return false;
}
Player[] spectators;
using (await this._spectatorLock.LockAsync())
{
if (this.Spectators.Count >= this._maximumSpectators)
{
return false;
}
spectators = this.Spectators.ToArray();
this.Spectators.Add(player);
}
await player.AddInvisibleEffectAsync().ConfigureAwait(false);
await player.WarpToAsync(spectatorsGate).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IInitializeDuelPlugIn>(p => p.InitializeDuelAsync(this)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IDuelHealthUpdatePlugIn>(p => p.UpdateHealthAsync(this)).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowDuelScoreUpdatePlugIn>(p => p.UpdateScoreAsync(this)).ConfigureAwait(false);
// send spectator list to this player
await player.InvokeViewPlugInAsync<IDuelSpectatorListUpdatePlugIn>(p => p.UpdateSpectatorListAsync(spectators)).ConfigureAwait(false);
// send spectator added to all spectators of the same room
for (int i = spectators.Length - 1; i >= 0; i--)
{
if (spectators[i] is { } spectator)
{
await spectator.InvokeViewPlugInAsync<IDuelSpectatorAddedPlugIn>(p => p.SpectatorAddedAsync(player)).ConfigureAwait(false);
}
}
return true;
}
/// <summary>
/// Resets and disposes of the duel room.
/// </summary>
/// <param name="startResult">The resuls of the duel start request.</param>
/// <returns>A <see cref="ValueTask"/>.</returns>
public async ValueTask ResetAndDisposeAsync(DuelStartResult startResult)
{
this.Requester.DuelRoom = null;
this.Opponent.DuelRoom = null;
await this.Requester.GameContext.DuelRoomManager.GiveBackDuelRoomAsync(this).ConfigureAwait(false);
if (startResult == DuelStartResult.Refused)
{
this.State = DuelState.DuelRefused;
await this.Requester.InvokeViewPlugInAsync<IShowDuelRequestResultPlugIn>(p => p.ShowDuelRequestResultAsync(startResult, this.Opponent)).ConfigureAwait(false);
}
else if (startResult != DuelStartResult.Undefined)
{
this.State = DuelState.DuelStartFailed;
await this.Requester.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync()).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync()).ConfigureAwait(false);
await this.Requester.InvokeViewPlugInAsync<IShowDuelRequestResultPlugIn>(p => p.ShowDuelRequestResultAsync(startResult, this.Opponent)).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IShowDuelRequestResultPlugIn>(p => p.ShowDuelRequestResultAsync(startResult, this.Requester)).ConfigureAwait(false);
}
else
{
await this.Requester.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync()).ConfigureAwait(false);
await this.Opponent.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync()).ConfigureAwait(false);
}
await this.DisposeAsyncCore().ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
if (Interlocked.Exchange(ref this._cts, null) is not { } cts)
{
return;
}
await cts.CancelAsync().ConfigureAwait(false);
if (this.State >= DuelState.DuelAccepted)
{
await this.MovePlayersToExitAsync().ConfigureAwait(false);
await this.Requester.GameContext.DuelRoomManager.GiveBackDuelRoomAsync(this).ConfigureAwait(false);
}
this.AllPlayers.ForEach(p => p.DuelRoom = null);
cts.Dispose();
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private async ValueTask MovePlayersToExitAsync()
{
var duelConfig = this.Requester.GameContext.Configuration.DuelConfiguration;
var exitGate = duelConfig?.Exit;
var duelArenaMapNumber = this.Area.FirstPlayerGate?.Map?.Number;
var players = this.AllPlayers
.Where(p => p.CurrentMap?.MapId == duelArenaMapNumber);
foreach (var player in players)
{
try
{
if (exitGate is not null && !this.IsDuelist(player))
{
await player.WarpToAsync(exitGate).ConfigureAwait(false);
}
else
{
await player.WarpToSafezoneAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
player.Logger.LogError(ex, "Unexpected error when moving player away from duel arena.");
}
}
}
private async ValueTask NotifyDuelFinishedAsync()
{
var winner = this.ScoreRequester > this.ScoreOpponent ? this.Requester : this.Opponent;
var loser = this.Requester == winner ? this.Opponent : this.Requester;
await this.AllPlayers.ForEachAsync(player => player.InvokeViewPlugInAsync<IDuelFinishedPlugIn>(p => p.DuelFinishedAsync(winner, loser))).ConfigureAwait(false);
}
private async ValueTask SendCurrentStateToAllPlayersAsync()
{
await this.AllPlayers.ForEachAsync(p => p.InvokeViewPlugInAsync<IShowDuelScoreUpdatePlugIn>(p => p.UpdateScoreAsync(this))).ConfigureAwait(false);
for (var index = this.Spectators.Count - 1; index >= 0; index--)
{
var spectator = this.Spectators[index];
await spectator.InvokeViewPlugInAsync<IDuelHealthUpdatePlugIn>(p => p.UpdateHealthAsync(this)).ConfigureAwait(false);
}
}
private async ValueTask StopDuelAsync()
{
await this.Opponent.ResetPetBehaviorAsync().ConfigureAwait(false);
await this.Requester.ResetPetBehaviorAsync().ConfigureAwait(false);
await this.ResetAndDisposeAsync(DuelStartResult.Undefined).ConfigureAwait(false);
await this.AllPlayers.ForEachAsync(player => player.InvokeViewPlugInAsync<IDuelEndedPlugIn>(p => p.DuelEndedAsync())).ConfigureAwait(false);
}
private async ValueTask FinishDuelAsync()
{
await this.Opponent.ResetPetBehaviorAsync().ConfigureAwait(false);
await this.Requester.ResetPetBehaviorAsync().ConfigureAwait(false);
await this.NotifyDuelFinishedAsync().ConfigureAwait(false);
await Task.Delay(10000, default).ConfigureAwait(false);
await this.MovePlayersToExitAsync().ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,93 @@
// <copyright file="DuelRoomManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
using MUnique.OpenMU.GameLogic.Views.Duel;
using Nito.AsyncEx;
/// <summary>
/// A class that manages several instances of <see cref="DuelRoom"/>.
/// </summary>
public class DuelRoomManager
{
private readonly DuelConfiguration _configuration;
private readonly AsyncLock _lock = new AsyncLock();
private readonly DuelRoom?[] _duelRooms;
/// <summary>
/// Initializes a new instance of the <see cref="DuelRoomManager"/> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
public DuelRoomManager(DuelConfiguration configuration)
{
this._configuration = configuration;
this._duelRooms = new DuelRoom?[this.MaxRoomCount];
}
/// <summary>
/// Gets the maximum <see cref="DuelRoom"/>s count.
/// </summary>
public int MaxRoomCount => this._configuration?.DuelAreas.Count ?? 0;
/// <summary>
/// Gets a <see cref="DuelRoom"/> for the two duelist players.
/// </summary>
/// <param name="player1">The first player.</param>
/// <param name="player2">The second player.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> with a free <see cref="DuelRoom"/>.</returns>
public async ValueTask<DuelRoom?> GetFreeDuelRoomAsync(Player player1, Player player2, CancellationToken cancellationToken = default)
{
using var l = await this._lock.LockAsync(cancellationToken);
for (int i = 0; i < this._duelRooms.Length; i++)
{
if (this._duelRooms[i] is null)
{
var area = this._configuration.DuelAreas.First(a => a.Index == i);
return this._duelRooms[i] = new DuelRoom(area, player1, player2);
}
}
return null;
}
/// <summary>
/// Discards a <see cref="DuelRoom"/>.
/// </summary>
/// <param name="duelRoom">The <see cref="DuelRoom"/>.</param>
/// <returns>A <see cref="ValueTask"/>.</returns>
public async ValueTask GiveBackDuelRoomAsync(DuelRoom duelRoom)
{
using var l = await this._lock.LockAsync();
this._duelRooms[duelRoom.Index] = null;
}
/// <summary>
/// Gets a <see cref="DuelRoom"/> by its index number.
/// </summary>
/// <param name="requestedDuelIndex">The index of the <see cref="DuelRoom"/>.</param>
/// <returns>A <see cref="DuelRoom"/>.</returns>
public DuelRoom? GetRoomByIndex(byte requestedDuelIndex)
{
if (this.MaxRoomCount <= requestedDuelIndex)
{
return null;
}
return this._duelRooms[requestedDuelIndex];
}
/// <summary>
/// Shows the <see cref="DuelRoom"/>s to a player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>A <see cref="ValueTask"/>.</returns>
public async ValueTask ShowRoomsAsync(Player player)
{
await player.InvokeViewPlugInAsync<IDuelStatusUpdatePlugIn>(p => p.UpdateStatusAsync(this._duelRooms)).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,134 @@
// <copyright file="EnumerableExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Extensions for <see cref="IEnumerable{T}"/>.
/// </summary>
public static class EnumerableExtensions
{
/// <summary>
/// Executes the <paramref name="action"/> for each element of <paramref name="enumerable"/>.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <param name="action">The action which should be executed for each element.</param>
/// <exception cref="System.ArgumentNullException">action.</exception>
public static void ForEach<T>(this IEnumerable<T> enumerable, Action<T> action)
{
if (action is null)
{
throw new ArgumentNullException(nameof(action));
}
foreach (var item in enumerable)
{
action(item);
}
}
/// <summary>
/// Executes the <paramref name="action"/> for each element of <paramref name="enumerable"/>.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <param name="action">The action which should be executed for each element.</param>
/// <exception cref="System.ArgumentNullException">action.</exception>
public static async ValueTask ForEachAsync<T>(this IEnumerable<T> enumerable, Func<T, ValueTask> action)
{
if (action is null)
{
throw new ArgumentNullException(nameof(action));
}
foreach (var item in enumerable)
{
await action(item).ConfigureAwait(false);
}
}
/// <summary>
/// Returns the enumerable as list, by either casting it or creating it.
/// </summary>
/// <typeparam name="T">The type of the list elements.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <returns>The list.</returns>
public static IList<T> AsList<T>(this IEnumerable<T> enumerable)
{
if (enumerable is IList<T> list)
{
return list;
}
return enumerable.ToList();
}
/// <summary>
/// Selects a random element of an enumerable.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <param name="randomizer">The randomizer.</param>
/// <returns>The randomly selected element.</returns>
public static T? SelectRandom<T>(this IEnumerable<T> enumerable, IRandomizer randomizer)
{
var list = enumerable as IList<T> ?? enumerable.ToList();
if (list.Count > 0)
{
var index = randomizer.NextInt(0, list.Count);
return list[index];
}
return default;
}
/// <summary>
/// Selects a random element of an enumerable.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <returns>The randomly selected element.</returns>
public static T? SelectRandom<T>(this IEnumerable<T> enumerable) => SelectRandom(enumerable, Rand.GetRandomizer());
/// <summary>
/// Selects a random weighted element of an enumerable.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <param name="weights">The weights associated with <paramref name="enumerable" />, respectively.</param>
/// <param name="randomizer">The randomizer.</param>
/// <returns>The randomly selected weighted element.</returns>
public static T? SelectWeightedRandom<T>(this IEnumerable<T> enumerable, IEnumerable<int> weights, IRandomizer randomizer)
{
var list = enumerable as IList<T> ?? enumerable.ToList();
var weightList = weights as IList<int> ?? weights.ToList();
if (list.Count > 0 && weightList.Count == list.Count)
{
var roll = randomizer.NextInt(0, weights.Sum());
int inc = 0;
for (int i = 0; i < weightList.Count; i++)
{
inc += weightList[i];
if (roll < inc)
{
return list[i];
}
}
return SelectRandom(enumerable, randomizer); // Fallback in case there are no weights assigned (>0)
}
return default;
}
/// <summary>
/// Selects a random weighted element of an enumerable.
/// </summary>
/// <typeparam name="T">The generic type of the enumerable.</typeparam>
/// <param name="enumerable">The enumerable.</param>
/// <param name="weights">The weights associated with <paramref name="enumerable" />, respectively.</param>
/// <returns>The randomly selected weighted element.</returns>
public static T? SelectWeightedRandom<T>(this IEnumerable<T> enumerable, IEnumerable<int> weights) => SelectWeightedRandom(enumerable, weights, Rand.GetRandomizer());
}

View File

@@ -0,0 +1,23 @@
// <copyright file="ExitGateExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Extensions for <see cref="ExitGate"/>.
/// </summary>
public static class ExitGateExtensions
{
/// <summary>
/// Gets a random point at the exit gate.
/// </summary>
/// <param name="gate">The gate.</param>
/// <returns>The random point.</returns>
public static Point GetRandomPoint(this ExitGate gate)
{
return new Point((byte)Rand.NextInt(gate.X1, gate.X2), (byte)Rand.NextInt(gate.Y1, gate.Y2));
}
}

View File

@@ -0,0 +1,76 @@
// <copyright file="FeaturePlugInContainer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A plugin container for <see cref="IFeaturePlugIn"/>s.
/// </summary>
public class FeaturePlugInContainer : PlugInContainerBase<IFeaturePlugIn>, ICustomPlugInContainer<IFeaturePlugIn>
{
private readonly ConcurrentDictionary<Type, IFeaturePlugIn> _currentlyEffectivePlugIns = new();
/// <summary>
/// Initializes a new instance of the <see cref="FeaturePlugInContainer"/> class.
/// </summary>
/// <param name="manager">The plugin manager which manages this instance.</param>
public FeaturePlugInContainer(PlugInManager manager)
: base(manager)
{
foreach (var plugIn in this.Manager.GetActivePlugInsOf<IFeaturePlugIn>())
{
if (!this._currentlyEffectivePlugIns.ContainsKey(plugIn.GetType()))
{
this.AddPlugIn(plugIn, true);
}
}
}
/// <inheritdoc />
public T? GetPlugIn<T>()
where T : class, IFeaturePlugIn
{
if (this._currentlyEffectivePlugIns.TryGetValue(typeof(T), out var plugIn) && plugIn is T t)
{
return t;
}
return default;
}
/// <inheritdoc />
protected override void ActivatePlugIn(IFeaturePlugIn plugIn)
{
var plugInType = plugIn.GetType();
if (this._currentlyEffectivePlugIns.ContainsKey(plugInType))
{
return;
}
base.ActivatePlugIn(plugIn);
this._currentlyEffectivePlugIns.TryAdd(plugInType, plugIn);
}
/// <inheritdoc />
protected override void DeactivatePlugIn(IFeaturePlugIn plugIn)
{
base.DeactivatePlugIn(plugIn);
this._currentlyEffectivePlugIns.TryRemove(plugIn.GetType(), out _);
}
/// <inheritdoc/>
protected override void BeforeActivatePlugInType(Type plugInType)
{
base.BeforeActivatePlugInType(plugInType);
var knownPlugIn = this.FindKnownPlugin(plugInType);
if (knownPlugIn is null)
{
this.AddPlugIn((IFeaturePlugIn)Activator.CreateInstance(plugInType)!, true);
}
}
}

View File

@@ -0,0 +1,535 @@
// <copyright file="GameContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using System.Globalization;
using System.Threading;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
using org.mariuszgromada.math.mxparser;
/// <summary>
/// The game context which holds all data of the game together.
/// </summary>
public class GameContext : AsyncDisposable, IGameContext
{
private const string DefaultExperienceFormula = "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))))";
private const string DefaultMasterExperienceFormula = "(505 * level * level * level) + (35278500 * level) + (228045 * level * level)";
private static readonly Meter Meter = new(MeterName);
private static readonly Counter<int> PlayerCounter = Meter.CreateCounter<int>("PlayerCount");
private static readonly Counter<int> MapCounter = Meter.CreateCounter<int>("MapCount");
private static readonly Counter<int> MiniGameCounter = Meter.CreateCounter<int>("MiniGameCount");
private static readonly IObjectPool<PathFinder> PathFinderPoolInstance = new LimitedObjectPool<PathFinder>(new PathFinderPoolingPolicy());
private readonly Dictionary<ushort, GameMap> _mapList = new();
private readonly Dictionary<MiniGameMapKey, MiniGameContext> _miniGames = new();
private readonly Timer _recoverTimer;
private readonly IMapInitializer _mapInitializer;
private readonly AsyncLock _mapInitializerLock = new();
private readonly Timer _tasksTimer;
private readonly AsyncReaderWriterLock _playerListLock = new();
/// <summary>
/// Keeps the list of all players.
/// </summary>
private readonly List<Player> _playerList = new();
private readonly IDisposable _configChangeHandlerRegistration;
/// <summary>
/// Initializes a new instance of the <see cref="GameContext" /> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="persistenceContextProvider">The persistence context provider.</param>
/// <param name="mapInitializer">The map initializer.</param>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="plugInManager">The plug in manager.</param>
/// <param name="dropGenerator">The drop generator.</param>
/// <param name="changeMediator">The cange mediator.</param>
public GameContext(GameConfiguration configuration, IPersistenceContextProvider persistenceContextProvider, IMapInitializer mapInitializer, ILoggerFactory loggerFactory, PlugInManager plugInManager, IDropGenerator dropGenerator, IConfigurationChangeMediator changeMediator)
{
try
{
this.Configuration = configuration;
this.PersistenceContextProvider = persistenceContextProvider;
this.PlugInManager = plugInManager;
this._mapInitializer = mapInitializer;
this.LoggerFactory = loggerFactory;
this.DropGenerator = dropGenerator;
this.ConfigurationChangeMediator = changeMediator;
this.ItemPowerUpFactory = new ItemPowerUpFactory(loggerFactory.CreateLogger<ItemPowerUpFactory>());
this.PartyManager = new PartyManager(configuration.MaximumPartySize, loggerFactory.CreateLogger<Party>());
this._recoverTimer = new Timer(this.RecoverTimerElapsed, null, this.Configuration.RecoveryInterval, this.Configuration.RecoveryInterval);
this._tasksTimer = new Timer(this.ExecutePeriodicTasks, null, 1000, 1000);
this.FeaturePlugIns = new FeaturePlugInContainer(this.PlugInManager);
this._configChangeHandlerRegistration = this.ConfigurationChangeMediator.RegisterObject(this.Configuration, this, this.OnGameConfigurationChangeAsync);
this.DuelRoomManager = new DuelRoomManager(this.Configuration.DuelConfiguration!);
this.ExperienceTable = CreateExpTable(this.Configuration.ExperienceFormula ?? DefaultExperienceFormula, this.Configuration.MaximumLevel);
this.MasterExperienceTable = CreateExpTable(this.Configuration.MasterExperienceFormula ?? DefaultMasterExperienceFormula, this.Configuration.MaximumMasterLevel);
}
catch (Exception ex)
{
loggerFactory.CreateLogger<GameContext>().LogError(ex, "Unexpected error in constructor of GameContext.");
throw;
}
}
/// <summary>
/// Occurs when a game map got created.
/// </summary>
public event EventHandler<GameMap>? GameMapCreated;
/// <summary>
/// Occurs when a game map got removed.
/// </summary>
/// <remarks>
/// Currently, maps are never removed.
/// It may make sense to remove unused maps after a certain period.
/// </remarks>
public event EventHandler<GameMap>? GameMapRemoved;
/// <inheritdoc />
public virtual float ExperienceRate => this.Configuration.ExperienceRate;
/// <inheritdoc />
public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate;
/// <inheritdoc />
public virtual bool PvpEnabled { get; }
/// <inheritdoc/>
public GameConfiguration Configuration { get; }
/// <inheritdoc/>
public long[] ExperienceTable { get; private set; }
/// <inheritdoc/>
public long[] MasterExperienceTable { get; private set; }
/// <inheritdoc/>
public IConfigurationChangeMediator ConfigurationChangeMediator { get; }
/// <inheritdoc/>
public PlugInManager PlugInManager { get; }
/// <inheritdoc/>
public IDropGenerator DropGenerator { get; }
/// <inheritdoc />
public FeaturePlugInContainer FeaturePlugIns { get; }
/// <inheritdoc />
public Offline.OfflinePlayerManager OfflinePlayerManager { get; } = new();
/// <inheritdoc/>
public IItemPowerUpFactory ItemPowerUpFactory { get; }
/// <inheritdoc/>
public IPersistenceContextProvider PersistenceContextProvider { get; }
/// <summary>
/// Gets the players by character name dictionary.
/// </summary>
public ConcurrentDictionary<string, Player> PlayersByCharacterName { get; } = new ConcurrentDictionary<string, Player>(StringComparer.OrdinalIgnoreCase);
/// <inheritdoc />
public DuelRoomManager DuelRoomManager { get; set; }
/// <inheritdoc />
public ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } = new();
/// <inheritdoc />
public IPartyManager PartyManager { get; }
/// <inheritdoc />
public ILoggerFactory LoggerFactory { get; }
/// <summary>
/// Gets the path finder pool.
/// </summary>
public IObjectPool<PathFinder> PathFinderPool => PathFinderPoolInstance;
/// <inheritdoc />
public int PlayerCount => this._playerList.Count;
/// <summary>
/// Gets the name of the meter of this class.
/// </summary>
internal static string MeterName => typeof(GameContext).FullName ?? nameof(GameContext);
/// <summary>
/// Gets the initialized maps which are hosted on this context.
/// </summary>
public async ValueTask<IEnumerable<GameMap>> GetMapsAsync()
{
using var l = await this._mapInitializerLock.LockAsync();
return this._mapList.Values.Concat(this._miniGames.Values.Select(g => g.Map)).ToList();
}
/// <inheritdoc/>
public async ValueTask<GameMap?> GetMapAsync(ushort mapId, bool createIfNotExists = true)
{
if (this._mapList.TryGetValue(mapId, out var map))
{
return map;
}
if (!createIfNotExists)
{
return null;
}
GameMap? createdMap;
using (await this._mapInitializerLock.LockAsync())
{
if (this._mapList.TryGetValue(mapId, out map))
{
return map;
}
createdMap = this._mapInitializer.CreateGameMap(mapId);
if (createdMap is null)
{
return null;
}
this._mapList.Add(mapId, createdMap);
createdMap.ObjectAdded += async args =>
{
if (this.PlugInManager.GetPlugInPoint<IObjectAddedToMapPlugIn>() is { } plugInPoint)
{
await plugInPoint.ObjectAddedToMapAsync(args.Map, args.Object).ConfigureAwait(false);
}
};
createdMap.ObjectRemoved += async args =>
{
if (this.PlugInManager.GetPlugInPoint<IObjectRemovedFromMapPlugIn>() is { } plugInPoint)
{
await plugInPoint.ObjectRemovedFromMapAsync(args.Map, args.Object).ConfigureAwait(false);
}
};
}
// ReSharper disable once InconsistentlySynchronizedField it's desired behavior to initialize the map outside the lock to keep locked timespan short.
await this._mapInitializer.InitializeStateAsync(createdMap).ConfigureAwait(false);
this.GameMapCreated?.Invoke(this, createdMap);
MapCounter.Add(1);
return createdMap;
}
/// <summary>
/// Gets the mini game map which is meant to be hosted by the game.
/// </summary>
/// <param name="miniGameDefinition">The mini game definition.</param>
/// <param name="requester">The requesting player.</param>
/// <returns>The hosted mini game instance.</returns>
public async ValueTask<MiniGameContext> GetMiniGameAsync(MiniGameDefinition miniGameDefinition, Player requester)
{
var miniGameKey = MiniGameMapKey.Create(miniGameDefinition, requester);
if (this._miniGames.TryGetValue(miniGameKey, out var miniGameContext) && miniGameContext is { IsDisposed: false, IsDisposing: false })
{
return miniGameContext;
}
using (await this._mapInitializerLock.LockAsync().ConfigureAwait(false))
{
if (this._miniGames.TryGetValue(miniGameKey, out miniGameContext))
{
if (miniGameContext.IsDisposed)
{
this._miniGames.Remove(miniGameKey);
}
else
{
return miniGameContext;
}
}
switch (miniGameDefinition.Type)
{
case MiniGameType.ChaosCastle:
miniGameContext = new ChaosCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
case MiniGameType.DevilSquare:
miniGameContext = new DevilSquareContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
case MiniGameType.BloodCastle:
miniGameContext = new BloodCastleContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
default:
miniGameContext = new MiniGameContext(miniGameKey, miniGameDefinition, this, this._mapInitializer);
break;
}
this._miniGames.Add(miniGameKey, miniGameContext);
}
var createdMap = miniGameContext.Map;
// ReSharper disable once InconsistentlySynchronizedField it's desired behavior to initialize the map outside the lock to keep locked timespan short.
await this._mapInitializer.InitializeStateAsync(createdMap).ConfigureAwait(false);
this.GameMapCreated?.Invoke(this, createdMap);
MiniGameCounter.Add(1);
return miniGameContext;
}
/// <inheritdoc />
public async ValueTask RemoveMiniGameAsync(MiniGameContext miniGameContext)
{
using var l = await this._mapInitializerLock.LockAsync().ConfigureAwait(false);
MiniGameCounter.Add(-1);
miniGameContext.Dispose();
this._miniGames.Remove(miniGameContext.Key);
this.GameMapRemoved?.Invoke(this, miniGameContext.Map);
}
/// <summary>
/// Adds the player to the game.
/// </summary>
/// <param name="player">The player.</param>
public virtual async ValueTask AddPlayerAsync(Player player)
{
player.PlayerLeftWorld += this.PlayerLeftWorldAsync;
player.PlayerEnteredWorld += this.PlayerEnteredWorldAsync;
player.PlayerDisconnected += this.RemovePlayerAsync;
using (await this._playerListLock.WriterLockAsync())
{
this._playerList.Add(player);
}
PlayerCounter.Add(1);
}
/// <inheritdoc />
public async ValueTask<IList<Player>> GetPlayersAsync()
{
using var l = await this._playerListLock.ReaderLockAsync();
if (this._playerList.Count == 0)
{
return [];
}
return this._playerList.ToList();
}
/// <summary>
/// Removes the player from the game.
/// </summary>
/// <param name="player">The player.</param>
public virtual async ValueTask RemovePlayerAsync(Player player)
{
bool removed;
using (await this._playerListLock.WriterLockAsync())
{
removed = this._playerList.Remove(player);
}
if (!removed)
{
return;
}
PlayerCounter.Add(-1);
if (player.SelectedCharacter != null)
{
this.PlayersByCharacterName.TryRemove(player.SelectedCharacter.Name, out _);
}
player.CurrentMap?.RemoveAsync(player);
player.PlayerDisconnected -= this.RemovePlayerAsync;
player.PlayerEnteredWorld -= this.PlayerEnteredWorldAsync;
player.PlayerLeftWorld -= this.PlayerLeftWorldAsync;
}
/// <summary>
/// Gets the player by the character name.
/// </summary>
/// <param name="name">The character name.</param>
/// <returns>The player by character name.</returns>
public Player? GetPlayerByCharacterName(string name)
{
this.PlayersByCharacterName.TryGetValue(name, out var player);
return player;
}
/// <inheritdoc />
public async ValueTask ForEachPlayerAsync(Func<Player, Task> action)
{
if (this._playerList.Count == 0)
{
return;
}
var playerList = await this.GetPlayersAsync().ConfigureAwait(false);
await playerList.Select(action).WhenAll().ConfigureAwait(false);
}
/// <summary>
/// Executes the specified action for each player, grouped by their culture.
/// </summary>
/// <param name="stateFactory">The state factory which creates a state for each culture group.</param>
/// <param name="action">The action to execute for each player and culture state.</param>
/// <typeparam name="TCultureState">The type of the culture state.</typeparam>
public async ValueTask ForEachPlayerGroupedByCultureAsync<TCultureState>(Func<CultureInfo, TCultureState> stateFactory, Func<Player, TCultureState, Task> action)
{
if (this._playerList.Count == 0)
{
return;
}
var playerList = await this.GetPlayersAsync().ConfigureAwait(false);
await playerList
.GroupBy(p => p.Culture)
.SelectMany(g =>
{
var state = stateFactory(g.Key);
return g.Select(player => action(player, state));
})
.WhenAll().ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask ShowGlobalLocalizedMessageAsync(MessageType messageType, string messageKey, params object?[] formatArguments)
{
await this.ForEachPlayerGroupedByCultureAsync<string>(
cultureInfo =>
{
if (formatArguments.Length > 0)
{
return string.Format(PlayerMessage.ResourceManager.GetString(messageKey, cultureInfo) ?? string.Empty, formatArguments);
}
return PlayerMessage.ResourceManager.GetString(messageKey, cultureInfo) ?? string.Empty;
},
(player, message) => player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, messageType)).AsTask())
.ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask SendGlobalMessageAsync(string message, MessageType messageType)
{
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, messageType)).AsTask()).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask SendGlobalChatMessageAsync(string sender, string message, ChatMessageType messageType)
{
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IChatViewPlugIn>(p => p.ChatMessageAsync(message, sender, messageType)).AsTask()).ConfigureAwait(false);
}
/// <inheritdoc/>
public async ValueTask SendGlobalNotificationAsync(string message)
{
var sendingMessage = message.TrimStart('!');
await this.SendGlobalMessageAsync(sendingMessage, MessageType.GoldenCenter).ConfigureAwait(false);
}
/// <inheritdoc/>
protected override async ValueTask DisposeAsyncCore()
{
this._configChangeHandlerRegistration.Dispose();
await this._recoverTimer.DisposeAsync().ConfigureAwait(false);
await this._tasksTimer.DisposeAsync().ConfigureAwait(false);
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private static long[] CreateExpTable(string experienceFormula, short maximumLevel)
{
var argument = new Argument("level");
var expression = new Expression(experienceFormula);
expression.addArguments(argument);
return Enumerable.Range(0, maximumLevel + 2)
.Select(level =>
{
argument.setArgumentValue(level);
return (long)expression.calculate();
})
.ToArray();
}
#pragma warning disable CS1998
private async ValueTask OnGameConfigurationChangeAsync(Action unregisterAction, GameConfiguration gameConfiguration, GameContext context)
#pragma warning restore CS1998
{
this._recoverTimer.Change(gameConfiguration.RecoveryInterval, gameConfiguration.RecoveryInterval);
this.ExperienceTable = CreateExpTable(gameConfiguration.ExperienceFormula ?? DefaultExperienceFormula, gameConfiguration.MaximumLevel);
this.MasterExperienceTable = CreateExpTable(gameConfiguration.MasterExperienceFormula ?? DefaultMasterExperienceFormula, gameConfiguration.MaximumMasterLevel);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void ExecutePeriodicTasks(object? state)
{
try
{
if (this.PlugInManager.GetPlugInPoint<IPeriodicTaskPlugIn>() is { } plugInPoint)
{
await plugInPoint.ExecuteTaskAsync(this).ConfigureAwait(false);
}
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void RecoverTimerElapsed(object? state)
{
try
{
await this.ForEachPlayerAsync(player =>
{
if (player.SelectedCharacter != null && !player.PlayerState.CurrentState.IsDisconnectedOrFinished())
{
return player.RegenerateAsync();
}
return Task.CompletedTask;
}).ConfigureAwait(false);
}
catch
{
// This should never happen as we already handle Exceptions in player.RegenerateAsync.
// However, if the player disconnects in the meantime, it could happen :-).
}
}
private ValueTask PlayerEnteredWorldAsync(Player player)
{
this.PlayersByCharacterName.TryAdd(player.SelectedCharacter!.Name, player);
return ValueTask.CompletedTask;
}
private ValueTask PlayerLeftWorldAsync(Player player)
{
this.PlayersByCharacterName.TryRemove(player.SelectedCharacter!.Name, out _);
return ValueTask.CompletedTask;
}
}

276
src/GameLogic/GameMap.cs Normal file
View File

@@ -0,0 +1,276 @@
// <copyright file="GameMap.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using System.Threading;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
/// <summary>
/// The game map which contains instances of players, npcs, drops, and more.
/// </summary>
public class GameMap
{
private readonly IDictionary<ushort, ILocateable> _objectsInMap = new ConcurrentDictionary<ushort, ILocateable>();
private readonly IAreaOfInterestManager _areaOfInterestManager;
private readonly IdGenerator _objectIdGenerator;
private readonly IdGenerator _dropIdGenerator;
private readonly ExitGate? _safezoneSpawnGate;
private int _playerCount;
/// <summary>
/// Initializes a new instance of the <see cref="GameMap" /> class.
/// </summary>
/// <param name="mapDefinition">The map definition.</param>
/// <param name="itemDropDuration">Duration of the item drop.</param>
/// <param name="chunkSize">Size of the chunk.</param>
public GameMap(GameMapDefinition mapDefinition, TimeSpan itemDropDuration, byte chunkSize)
{
this.Id = Guid.NewGuid();
this.Definition = mapDefinition;
this.ItemDropDuration = itemDropDuration;
this.Terrain = new GameMapTerrain(this.Definition);
this._areaOfInterestManager = new BucketAreaOfInterestManager(chunkSize);
this._objectIdGenerator = new IdGenerator(ViewExtensions.ConstantPlayerId + 1, 0x7FFF);
this._dropIdGenerator = new IdGenerator(0, ViewExtensions.ConstantPlayerId - 1);
this._safezoneSpawnGate = this.Definition.GetSafezoneGate(this.Terrain);
}
/// <summary>
/// Occurs when an object was added to the map.
/// </summary>
public event AsyncEventHandler<(GameMap Map, ILocateable Object)>? ObjectAdded;
/// <summary>
/// Occurs when an object was removed from the map.
/// </summary>
public event AsyncEventHandler<(GameMap Map, ILocateable Object)>? ObjectRemoved;
/// <summary>
/// Gets the map identifier.
/// </summary>
public ushort MapId => this.Definition.Number.ToUnsigned();
/// <summary>
/// Gets the terrain of the map.
/// </summary>
public GameMapTerrain Terrain { get; }
/// <summary>
/// Gets the safe zone spawn gate.
/// </summary>
public ExitGate? SafeZoneSpawnGate => this._safezoneSpawnGate;
/// <summary>
/// Gets the duration about how long drops are laying on the ground until they are disappearing.
/// </summary>
public TimeSpan ItemDropDuration { get; }
/// <summary>
/// Gets the definition of the map.
/// </summary>
public GameMapDefinition Definition { get; }
/// <summary>
/// Gets the unique identifier of this map instance.
/// </summary>
public Guid Id { get; }
/// <summary>
/// Gets the object with the specified identifier.
/// </summary>
/// <param name="id">The identifier.</param>
/// <returns>The object with the specified identifier.</returns>
public ILocateable? GetObject(ushort id)
{
this._objectsInMap.TryGetValue(id, out var result);
return result;
}
/// <summary>
/// Gets the attackables in range of the specified coordinates.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>The attackables in range of the specified coordinate.</returns>
public IList<IAttackable> GetAttackablesInRange(Point point, int range)
{
return this._areaOfInterestManager.GetInRange(point, range).OfType<IAttackable>().ToList();
}
/// <summary>
/// Gets all dropped items and money within the specified range of a point.
/// </summary>
/// <param name="point">The coordinates.</param>
/// <param name="range">The range.</param>
/// <returns>Dropped items and money in range.</returns>
public IList<ILocateable> GetDropsInRange(Point point, int range)
{
return this._areaOfInterestManager.GetInRange(point, range)
.Where(l => l is DroppedItem or DroppedMoney)
.ToList();
}
/// <summary>
/// Gets the drop by id.
/// </summary>
/// <param name="dropId">The drop identifier.</param>
/// <returns>The dropped item.</returns>
public ILocateable? GetDrop(ushort dropId)
{
this._objectsInMap.TryGetValue(dropId, out var item);
return item;
}
/// <summary>
/// Removes the locateable from the map.
/// </summary>
/// <param name="locateable">The locateable.</param>
public async ValueTask RemoveAsync(ILocateable locateable)
{
await this._areaOfInterestManager.RemoveObjectAsync(locateable).ConfigureAwait(false);
if (this._objectsInMap.Remove(locateable.Id) && locateable.Id != 0)
{
if (locateable is DroppedItem
|| locateable is DroppedMoney)
{
this._dropIdGenerator.GiveBack(locateable.Id);
}
else
{
this._objectIdGenerator.GiveBack(locateable.Id);
}
if (locateable is Player player)
{
player.Id = 0;
Interlocked.Decrement(ref this._playerCount);
}
if (this.ObjectRemoved is { } eventHandler)
{
await eventHandler((this, locateable)).ConfigureAwait(false);
}
}
}
/// <summary>
/// Adds the locateable to the map.
/// </summary>
/// <param name="locateable">The locateable object.</param>
public async ValueTask AddAsync(ILocateable locateable)
{
if (!this._objectsInMap.TryGetValue(locateable.Id, out var existing)
|| existing != locateable)
{
switch (locateable)
{
case DroppedItem droppedItem:
droppedItem.Id = (ushort)this._dropIdGenerator.GenerateId();
break;
case DroppedMoney droppedMoney:
droppedMoney.Id = (ushort)this._dropIdGenerator.GenerateId();
break;
case Player player:
player.Id = (ushort)this._objectIdGenerator.GenerateId();
Interlocked.Increment(ref this._playerCount);
break;
case NonPlayerCharacter npc:
npc.Id = (ushort)this._objectIdGenerator.GenerateId();
break;
case ISupportIdUpdate idUpdate:
idUpdate.Id = (ushort)this._objectIdGenerator.GenerateId();
break;
default:
throw new ArgumentException($"Adding an object of type {locateable.GetType()} is not supported.");
}
this._objectsInMap.Add(locateable.Id, locateable);
}
await this._areaOfInterestManager.AddObjectAsync(locateable).ConfigureAwait(false);
if (this.ObjectAdded is { } eventHandler)
{
await eventHandler((this, locateable)).ConfigureAwait(false);
}
}
/// <summary>
/// Moves the locatable on the map.
/// </summary>
/// <param name="locatable">The monster.</param>
/// <param name="target">The new coordinates.</param>
/// <param name="moveLock">The move lock.</param>
/// <param name="moveType">Type of the move.</param>
public ValueTask MoveAsync(ILocateable locatable, Point target, AsyncLock moveLock, MoveType moveType)
{
return this._areaOfInterestManager.MoveObjectAsync(locatable, target, moveLock, moveType);
}
/// <summary>
/// Initializes a respawn for the specified locateable.
/// </summary>
/// <param name="locateable">The locateable.</param>
public async ValueTask InitRespawnAsync(ILocateable locateable)
{
await this._areaOfInterestManager.RemoveObjectAsync(locateable).ConfigureAwait(false);
}
/// <summary>
/// Respawns the specified locateable.
/// </summary>
/// <param name="locateable">The locateable.</param>
public async ValueTask RespawnAsync(ILocateable locateable)
{
await this._areaOfInterestManager.RemoveObjectAsync(locateable).ConfigureAwait(false);
await this._areaOfInterestManager.AddObjectAsync(locateable).ConfigureAwait(false);
}
/// <summary>
/// Clears event NPCs.
/// </summary>
public async ValueTask ClearEventSpawnedNpcsAsync()
{
var eventMonsters = this._objectsInMap.Values
.OfType<NonPlayerCharacter>()
.Where(n => n.SpawnArea.SpawnTrigger is not SpawnTrigger.Automatic)
.ToList();
foreach (var monster in eventMonsters)
{
await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false);
monster.Dispose();
}
}
/// <summary>
/// Clears the drops on invalid terrain.
/// </summary>
public async ValueTask ClearDropsOnInvalidTerrainAsync()
{
var drops = this._objectsInMap.Values
.OfType<DroppedItem>()
.Where(d => !this.Terrain.WalkMap[d.Position.X, d.Position.Y])
.ToList();
foreach (var drop in drops)
{
await drop.DisposeAsync().ConfigureAwait(false);
}
}
}

View File

@@ -0,0 +1,167 @@
// <copyright file="GameMapTerrain.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Runtime.CompilerServices;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// The terrain of a map.
/// </summary>
public class GameMapTerrain
{
/// <summary>
/// The size of the map in each dimension (byte range: 0255).
/// </summary>
private const int MapSize = 256;
/// <summary>
/// The default terrain where all coordinates are walkable and not a safezone.
/// </summary>
private static readonly byte[] DefaultTerrain = Enumerable.Repeat<byte>(0, short.MaxValue).ToArray();
/// <summary>
/// Pre-computed array of walkable, non-safezone points.
/// Built once during construction for O(1) random spawn lookups.
/// </summary>
private readonly Point[] _spawnPoints;
/// <summary>
/// Initializes a new instance of the <see cref="GameMapTerrain"/> class.
/// </summary>
/// <param name="definition">The game map definition.</param>
public GameMapTerrain(GameMapDefinition definition)
: this(definition?.TerrainData)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="GameMapTerrain"/> class.
/// </summary>
/// <param name="terrainData">The terrain data.</param>
public GameMapTerrain(byte[]? terrainData)
{
if (terrainData is { })
{
this.ReadTerrainData(terrainData.AsSpan(3));
}
else
{
this.ReadTerrainData(DefaultTerrain);
}
this._spawnPoints = this.BuildSpawnPoints();
}
/// <summary>
/// Gets a grid of all safezone coordinates.
/// </summary>
public bool[,] SafezoneMap { get; } = new bool[MapSize, MapSize];
/// <summary>
/// Gets a grid of all walkable coordinates.
/// </summary>
public bool[,] WalkMap { get; } = new bool[MapSize, MapSize];
/// <summary>
/// Gets a grid of the walkable coordinates of monsters.
/// </summary>
public byte[,] AIgrid { get; } = new byte[MapSize, MapSize];
/// <summary>
/// Gets a random walkable, non-safezone point anywhere on the map.
/// Samples from a pre-computed array in O(1) per call.
/// </summary>
public Point? RandomWalkableCoordinate
{
get
{
var points = this._spawnPoints;
if (points.Length == 0)
{
return null;
}
return points[Random.Shared.Next(points.Length)];
}
}
/// <summary>
/// Gets a random drop coordinate at the specified point in the specified radius.
/// </summary>
/// <param name="point">The target point.</param>
/// <param name="maximumRadius">The maximum radius around the specified coordinate.</param>
/// <returns>The random drop coordinate.</returns>
public Point GetRandomCoordinate(Point point, byte maximumRadius)
{
byte tempx = (byte)Rand.NextInt(Math.Max(0, point.X - maximumRadius), Math.Min(255, point.X + maximumRadius + 1));
byte tempy = (byte)Rand.NextInt(Math.Max(0, point.Y - maximumRadius), Math.Min(255, point.Y + maximumRadius + 1));
int i = 0;
while (!this.WalkMap[tempx, tempy] && i < 20)
{
tempx = (byte)Rand.NextInt(Math.Max(0, point.X - maximumRadius), Math.Min(255, point.X + maximumRadius + 1));
tempy = (byte)Rand.NextInt(Math.Max(0, point.Y - maximumRadius), Math.Min(255, point.Y + maximumRadius + 1));
i++;
}
if (i == 20)
{
return point;
}
return new Point(tempx, tempy);
}
/// <summary>
/// Updates the ai grid value at the specified coordinate.
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void UpdateAiGridValue(byte x, byte y)
{
this.AIgrid[x, y] = (byte)((this.WalkMap[x, y] ? 1 : 0) | (this.SafezoneMap[x, y] ? 0b1000_0000 : 0));
}
/// <summary>
/// Reads the terrain data from a stream.
/// </summary>
/// <param name="data">The data.</param>
private void ReadTerrainData(ReadOnlySpan<byte> data)
{
for (int i = 0; i < data.Length; i++)
{
byte x = (byte)(i & 0xFF);
byte y = (byte)((i >> 8) & 0xFF);
byte value = data[i];
this.WalkMap[x, y] = value == 0 || value == 1;
this.SafezoneMap[x, y] = value == 1;
this.UpdateAiGridValue(x, y);
}
}
/// <summary>
/// Builds the array of valid spawn points.
/// A valid spawn point is walkable and not in a safezone.
/// </summary>
/// <returns>Array of valid spawn points.</returns>
private Point[] BuildSpawnPoints()
{
var result = new List<Point>(MapSize * MapSize);
for (var x = 0; x < MapSize; x++)
{
for (var y = 0; y < MapSize; y++)
{
if (this.WalkMap[x, y] && !this.SafezoneMap[x, y])
{
result.Add(new Point((byte)x, (byte)y));
}
}
}
return result.ToArray();
}
}

View File

@@ -0,0 +1,14 @@
// <copyright file="GlobalUsings.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
#pragma warning disable SA1200 // Using directives should be placed correctly
global using System.ComponentModel.DataAnnotations;
global using Microsoft.Extensions.Logging;
global using MUnique.OpenMU.DataModel;
global using MUnique.OpenMU.DataModel.Configuration;
global using MUnique.OpenMU.DataModel.Entities;
global using MUnique.OpenMU.GameLogic.Properties;

View File

@@ -0,0 +1,28 @@
// <copyright file="GuildEventArgs.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Event args for a deleted guild.
/// </summary>
public class GuildEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="GuildEventArgs"/> class.
/// </summary>
/// <param name="guildId">The guild identifier of the deleted guild.</param>
public GuildEventArgs(uint guildId)
{
this.GuildId = guildId;
}
/// <summary>
/// Gets the guild identifier of the deleted guild.
/// </summary>
/// <value>
/// The guild identifier of the deleted guild.
/// </value>
public uint GuildId { get; }
}

View File

@@ -0,0 +1,66 @@
// <copyright file="GuildWarContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.GuildWar;
/// <summary>
/// Holds the information about an ongoing guild war.
/// </summary>
public class GuildWarContext
{
/// <summary>
/// Initializes a new instance of the <see cref="GuildWarContext"/> class.
/// </summary>
/// <param name="warType">Type of the war.</param>
/// <param name="score">The score.</param>
/// <param name="team">The team.</param>
/// <param name="requester">The requester.</param>
public GuildWarContext(GuildWarType warType, GuildWarScore score, GuildWarTeam team, Player? requester)
{
this.WarType = warType;
this.Score = score;
this.Team = team;
this.Requester = requester;
}
/// <summary>
/// Gets the type of the war.
/// </summary>
public GuildWarType WarType { get; }
/// <summary>
/// Gets the score.
/// </summary>
public GuildWarScore Score { get; }
/// <summary>
/// Gets the team.
/// </summary>
public GuildWarTeam Team { get; }
/// <summary>
/// Gets the initial requester.
/// </summary>
public Player? Requester { get; }
/// <summary>
/// Gets or sets the state.
/// </summary>
public GuildWarState State { get; set; }
/// <summary>
/// Gets the name of the enemy team.
/// </summary>
public string EnemyTeamName => this.Team == GuildWarTeam.First ? this.Score.SecondGuildName : this.Score.FirstGuildName;
/// <summary>
/// Gets the score for this guild.
/// </summary>
public byte ThisScore => this.Team == GuildWarTeam.First ? this.Score.FirstGuildScore : this.Score.SecondGuildScore;
/// <summary>
/// Gets the score of the enemy guild.
/// </summary>
public byte EnemyScore => this.Team == GuildWarTeam.First ? this.Score.SecondGuildScore : this.Score.FirstGuildScore;
}

View File

@@ -0,0 +1,103 @@
// <copyright file="GuildWarScore.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.GuildWar;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Threading;
/// <summary>
/// The score of a guild war.
/// </summary>
/// <seealso cref="System.ComponentModel.INotifyPropertyChanged" />
public class GuildWarScore : INotifyPropertyChanged
{
private uint _firstGuildScore;
private uint _secondGuildScore;
/// <summary>
/// Occurs when a property value changes.
/// </summary>
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>
/// Gets the name of the first guild.
/// </summary>
public string FirstGuildName { get; init; } = string.Empty;
/// <summary>
/// Gets the name of the second guild.
/// </summary>
public string SecondGuildName { get; init; } = string.Empty;
/// <summary>
/// Gets the score of the first guild.
/// </summary>
public byte FirstGuildScore => (byte)Math.Min(byte.MaxValue, this._firstGuildScore);
/// <summary>
/// Gets the score of the second guild.
/// </summary>
public byte SecondGuildScore => (byte)Math.Min(byte.MaxValue, this._secondGuildScore);
/// <summary>
/// Gets a value indicating whether the guild war has ended.
/// </summary>
public bool HasEnded => this._firstGuildScore >= this.MaximumScore || this._secondGuildScore >= this.MaximumScore;
/// <summary>
/// Gets the maximum score.
/// </summary>
public byte MaximumScore { get; init; }
/// <summary>
/// Gets the winners of the guild war.
/// </summary>
public GuildWarTeam? Winners { get; private set; }
/// <summary>
/// Increases the score of the first guild.
/// </summary>
/// <param name="value">The value.</param>
public void IncreaseFirstGuildScore(byte value = 1)
{
if (!this.HasEnded)
{
Interlocked.Add(ref this._firstGuildScore, value);
this.RaisePropertyChanged(nameof(this.FirstGuildScore));
if (this.HasEnded)
{
this.Winners = GuildWarTeam.First;
this.RaisePropertyChanged(nameof(this.HasEnded));
this.PropertyChanged = null;
}
}
}
/// <summary>
/// Increases the score of the first guild.
/// </summary>
/// <param name="value">The value.</param>
public void IncreaseSecondGuildScore(byte value = 1)
{
if (!this.HasEnded)
{
Interlocked.Add(ref this._secondGuildScore, value);
this.RaisePropertyChanged(nameof(this.SecondGuildScore));
if (this.HasEnded)
{
this.Winners = GuildWarTeam.Second;
this.RaisePropertyChanged(nameof(this.HasEnded));
this.PropertyChanged = null;
}
}
}
private void RaisePropertyChanged([CallerMemberName] string propertyName = "")
{
this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="GuildWarState.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.GuildWar;
/// <summary>
/// The state of a guild war.
/// </summary>
public enum GuildWarState
{
/// <summary>
/// The guild war was requested.
/// </summary>
Requested,
/// <summary>
/// The guild war was started and is ongoing.
/// </summary>
Started,
/// <summary>
/// The guild war has ended.
/// </summary>
Ended,
}

View File

@@ -0,0 +1,21 @@
// <copyright file="GuildWarTeam.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.GuildWar;
/// <summary>
/// Defines the team of a guild war.
/// </summary>
public enum GuildWarTeam
{
/// <summary>
/// The first team.
/// </summary>
First,
/// <summary>
/// The second team.
/// </summary>
Second,
}

View File

@@ -0,0 +1,21 @@
// <copyright file="GuildWarType.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.GuildWar;
/// <summary>
/// Defines the type of a guild war.
/// </summary>
public enum GuildWarType
{
/// <summary>
/// A normal guild war, where two parties fight against each other on a normal game map.
/// </summary>
Normal,
/// <summary>
/// A battle soccer match, where two parties play against each other on a <see cref="SoccerGameMap"/>.
/// </summary>
Soccer,
}

10
src/GameLogic/HitInfo.cs Normal file
View File

@@ -0,0 +1,10 @@
// <copyright file="HitInfo.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The information about a hit.
/// </summary>
public record struct HitInfo(uint HealthDamage, uint ShieldDamage, DamageAttributes Attributes, uint ManaToll = 0);

View File

@@ -0,0 +1,44 @@
// <copyright file="IAreaOfInterestManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.Pathfinding;
using Nito.AsyncEx;
/// <summary>
/// A manager of an area of interest.
/// </summary>
public interface IAreaOfInterestManager
{
/// <summary>
/// Adds the object to the area of interest.
/// </summary>
/// <param name="obj">The object.</param>
ValueTask AddObjectAsync(ILocateable obj);
/// <summary>
/// Removes the object from the area of interest.
/// </summary>
/// <param name="obj">The object.</param>
ValueTask RemoveObjectAsync(ILocateable obj);
/// <summary>
/// Moves the object.
/// </summary>
/// <param name="obj">The object.</param>
/// <param name="target">The new coordinates.</param>
/// <param name="moveLock">The move lock.</param>
/// <param name="moveType">Type of the move.</param>
ValueTask MoveObjectAsync(ILocateable obj, Point target, AsyncLock moveLock, MoveType moveType);
/// <summary>
/// Gets the object in range.
/// </summary>
/// <param name="point">The point at which the objects are searched in the specified range.</param>
/// <param name="range">The range.</param>
/// <returns>The objects in range.</returns>
IEnumerable<ILocateable> GetInRange(Point point, int range);
}

View File

@@ -0,0 +1,101 @@
// <copyright file="IAttackable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.AttributeSystem;
/// <summary>
/// Interface for an object which is attackable.
/// </summary>
public interface IAttackable : IIdentifiable, ILocateable
{
/// <summary>
/// Occurs when this instance died.
/// </summary>
event EventHandler<DeathInformation>? Died;
/// <summary>
/// Gets the attributes.
/// </summary>
IAttributeSystem Attributes { get; }
/// <summary>
/// Gets the magic effect list which contains buffs and de-buffs.
/// </summary>
MagicEffectsList MagicEffectList { get; }
/// <summary>
/// Gets a value indicating whether this <see cref="IAttackable"/> is alive.
/// </summary>
/// <value>
/// <c>true</c> if alive; otherwise, <c>false</c>.
/// </value>
bool IsAlive { get; }
/// <summary>
/// Gets a value indicating whether this <see cref="IAttackable"/> is currently teleporting and can't be directly targeted.
/// It can still receive damage, if the teleport target coordinates are within an target skill area for area attacks.
/// </summary>
/// <value>
/// <c>true</c> if teleporting; otherwise, <c>false</c>.
/// </value>
bool IsTeleporting { get; }
/// <summary>
/// Gets the information about the last death.
/// </summary>
DeathInformation? LastDeath { get; }
/// <summary>
/// Attacks this object by the attacker with the specified skill.
/// </summary>
/// <param name="attacker">The attacker.</param>
/// <param name="skill">The skill.</param>
/// <param name="isCombo">If set to <c>true</c>, the attacker did a combination of skills.</param>
/// <param name="damageFactor">The damage factor.</param>
/// <param name="isFinalStreakHit">
/// Not <c>null</c> when it's a rage fighter multiple hit skill:
/// <c>true</c>, if it's the final hit;
/// <c>false</c>, for other hits.
/// </param>
/// <returns>Returns information about the damage inflicted.</returns>
ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null);
/// <summary>
/// Reflects the damage which was done previously with <see cref="AttackByAsync" /> or even <see cref="ReflectDamageAsync" /> to the <paramref name="reflector" />.
/// </summary>
/// <param name="reflector">The reflector.</param>
/// <param name="damage">The damage.</param>
ValueTask ReflectDamageAsync(IAttacker reflector, uint damage);
/// <summary>
/// Applies the poison damage.
/// </summary>
/// <param name="initialAttacker">The initial attacker.</param>
/// <param name="damage">The damage.</param>
ValueTask ApplyPoisonDamageAsync(IAttacker initialAttacker, uint damage);
/// <summary>
/// Applies the bleeding damage.
/// </summary>
/// <param name="initialAttacker">The initial attacker.</param>
/// <param name="damage">The damage.</param>
ValueTask ApplyBleedingDamageAsync(IAttacker initialAttacker, uint damage);
/// <summary>
/// Kills the attackable instantly.
/// </summary>
ValueTask KillInstantlyAsync();
}
/// <summary>
/// Contains information about the last death of the object.
/// </summary>
/// <param name="KillerId">The id of the killer.</param>
/// <param name="KillerName">The name of the killer.</param>
/// <param name="FinalHit">The hit info of the final/lethal hit.</param>
/// <param name="SkillNumber">The number of the used skill.</param>
// [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1313:Parameter names should begin with lower-case letter", Justification = "The names are affecting the property names of the record. We want upper case there.")]
public record DeathInformation(ushort KillerId, string KillerName, HitInfo FinalHit, short SkillNumber);

View File

@@ -0,0 +1,23 @@
// <copyright file="IAttacker.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.AttributeSystem;
/// <summary>
/// Interface for an object which can attack.
/// </summary>
public interface IAttacker : IIdentifiable, ILocateable
{
/// <summary>
/// Gets the attributes.
/// </summary>
IAttributeSystem Attributes { get; }
/// <summary>
/// Gets the state of the combo, if the attacker supports combos.
/// </summary>
ComboStateMachine? ComboState { get; }
}

View File

@@ -0,0 +1,45 @@
// <copyright file="IBucketMapObserver.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface of an object which is observing other objects on its current map.
/// </summary>
public interface IBucketMapObserver
{
/// <summary>
/// Gets the maximum distance of which buckets in range the observer is interested in.
/// </summary>
int InfoRange { get; }
/// <summary>
/// Gets the observing buckets.
/// </summary>
IList<Bucket<ILocateable>> ObservingBuckets { get; }
/// <summary>
/// This method is called, when another locateable is moving into the observing zones.
/// </summary>
/// <param name="item">The item which got added.</param>
ValueTask LocateableAddedAsync(ILocateable item);
/// <summary>
/// This method is called, when another locateable is moving out of the observing zones.
/// </summary>
/// <param name="item">The item which got removed.</param>
ValueTask LocateableRemovedAsync(ILocateable item);
/// <summary>
/// This method is called, when this object is moving to another zone, and old objects are getting out of range.
/// </summary>
/// <param name="oldObjects">The objects which are out of range.</param>
ValueTask LocateablesOutOfScopeAsync(IEnumerable<ILocateable> oldObjects);
/// <summary>
/// This method is called, when this object is moving to another zone, and new objects are getting into range.
/// </summary>
/// <param name="newObjects">The objects which are getting into range.</param>
ValueTask NewLocateablesInScopeAsync(IEnumerable<ILocateable> newObjects);
}

View File

@@ -0,0 +1,64 @@
// <copyright file="IConfigurationChangeMediator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// A mediator which notifies about configuration changes for registered instances.
/// </summary>
public interface IConfigurationChangeMediator
{
/// <summary>
/// Registers for changes of a configuration object in addition with an actual game logic object.
/// </summary>
/// <typeparam name="TConfig">The type of the configuration.</typeparam>
/// <typeparam name="T">The type of the game logic object which might be modified by the changes.</typeparam>
/// <param name="config">The configuration object in which the caller is interested in.</param>
/// <param name="obj">The game logic object which will be provided in the <paramref name="onChange"/> and <paramref name="onDelete"/> callbacks.</param>
/// <param name="onChange">The on-change callback.</param>
/// <param name="onDelete">The on-delete callback.</param>
/// <returns>An <see cref="IDisposable"/> which is used to dispose the registration.</returns>
IDisposable RegisterObject<TConfig, T>(TConfig config, T obj, Func<Action, TConfig, T, ValueTask>? onChange = null, Func<TConfig, T, ValueTask>? onDelete = null)
where T : class
where TConfig : class;
/// <summary>
/// Registers for created configuration objects of a specific type.
/// </summary>
/// <typeparam name="TConfig">The type of the configuration.</typeparam>
/// <typeparam name="T">The type of the game logic object which might be modified by the changes.</typeparam>
/// <param name="obj">The game logic object which will be provided in the <paramref name="onNewConfig"/> callback.</param>
/// <param name="onNewConfig">The on-new-configuration callback.</param>
/// <returns>An <see cref="IDisposable"/> which is used to dispose the registration.</returns>
IDisposable RegisterForNew<TConfig, T>(T obj, Func<TConfig, T, ValueTask> onNewConfig);
}
/// <summary>
/// The listener interface for a mediator which notifies about configuration changes for registered instances.
/// </summary>
public interface IConfigurationChangeMediatorListener
{
/// <summary>
/// Handles the changed configuration.
/// </summary>
/// <param name="type">The type of the changed configuration object.</param>
/// <param name="id">The identifier of the changed configuration object.</param>
/// <param name="configuration">The changed configuration object.</param>
ValueTask HandleConfigurationChangedAsync(Type type, Guid id, object configuration);
/// <summary>
/// Handles the added configuration.
/// </summary>
/// <param name="type">The type of the added configuration object.</param>
/// <param name="id">The identifier of the added configuration object.</param>
/// <param name="configuration">The added configuration object.</param>
ValueTask HandleConfigurationAddedAsync(Type type, Guid id, object configuration);
/// <summary>
/// Handles the removed configuration.
/// </summary>
/// <param name="type">The type of the removed configuration object.</param>
/// <param name="id">The identifier of the removed configuration object.</param>
ValueTask HandleConfigurationRemovedAsync(Type type, Guid id);
}

View File

@@ -0,0 +1,12 @@
// <copyright file="IDisabledByDefault.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface to indicate that this should be disabled by default.
/// </summary>
public interface IDisabledByDefault
{
}

View File

@@ -0,0 +1,36 @@
// <copyright file="IDropGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The interface for a drop generator.
/// </summary>
public interface IDropGenerator
{
/// <summary>
/// Gets the item drops which are generated when a monster got killed by a player.
/// </summary>
/// <param name="monster">The monster which got killed.</param>
/// <param name="gainedExperience">The experience which the player gained form the kill (relevant for the money drop).</param>
/// <param name="player">The player who killed the monster.</param>
/// <returns>
/// The item drops and money which are generated when a monster got killed by a player.
/// </returns>
ValueTask<(IEnumerable<Item> Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player);
/// <summary>
/// Generates an item based on a <see cref="DropItemGroup"/>.
/// </summary>
/// <param name="group">The <see cref="DropItemGroup"/> which defines which item should be generated.</param>
/// <returns>The generated item or <see langword="null"/>.</returns>
Item? GenerateItemDrop(DropItemGroup group);
/// <summary>
/// Generates an item based on a <see cref="DropItemGroup"/>s.
/// </summary>
/// <param name="groups">The <see cref="DropItemGroup"/>s which define which item should be generated.</param>
/// <returns>The generated item, money and drop effect of the selected group.</returns>
(Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable<DropItemGroup> groups);
}

View File

@@ -0,0 +1,26 @@
// <copyright file="IEventStateProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Provides a flag, if the event is running.
/// </summary>
/// <remarks>
/// It has effect on re-spawning monsters, when the <see cref="MonsterSpawnArea.SpawnTrigger"/> is <see cref="SpawnTrigger.AutomaticDuringEvent"/>.
/// </remarks>
public interface IEventStateProvider
{
/// <summary>
/// Gets a value indicating whether the event is currently running.
/// </summary>
bool IsEventRunning { get; }
/// <summary>
/// Determines, if a spawn wave is currently active.
/// </summary>
/// <param name="waveNumber">The number of the wave.</param>
/// <returns><see langword="true"/>, when the spawn wave is active; Otherwise, <see langword="false"/>.</returns>
bool IsSpawnWaveActive(byte waveNumber);
}

View File

@@ -0,0 +1,20 @@
// <copyright file="IFeaturePlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Runtime.InteropServices;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A plugin interface for features.
/// A feature plugin can be used to implicitly group dependent plugins.
/// For example, you can check if a feature plugin is active by calling <see cref="PlugInManager.IsPlugInActive(System.Type)" />.
/// Additionally, feature plugins can be used as a common configuration sink by implementing <see cref="ISupportCustomConfiguration{TCustomConfig}"/>.
/// </summary>
[Guid("D786314A-4168-4FCF-93F8-A350AD0E752E")]
[PlugInPoint("Feature Plugins", "Feature plugins can group other plugins and provide a configuration.")]
public interface IFeaturePlugIn
{
}

View File

@@ -0,0 +1,217 @@
// <copyright file="IGameContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// The context of the game.
/// </summary>
public interface IGameContext
{
/// <summary>
/// Occurs when a game map got created.
/// </summary>
event EventHandler<GameMap>? GameMapCreated;
/// <summary>
/// Occurs when a game map got removed.
/// </summary>
event EventHandler<GameMap>? GameMapRemoved;
/// <summary>
/// Gets the global experience rate.
/// </summary>
float ExperienceRate { get; }
/// <summary>
/// Gets the global master experience rate.
/// </summary>
float MasterExperienceRate { get; }
/// <summary>
/// Gets a value indicating whether PVP is enabled.
/// </summary>
bool PvpEnabled { get; }
/// <summary>
/// Gets the repository provider. Used to retrieve data, e.g. from a database.
/// </summary>
IPersistenceContextProvider PersistenceContextProvider { get; }
/// <summary>
/// Gets the item power up factory.
/// </summary>
IItemPowerUpFactory ItemPowerUpFactory { get; }
/// <summary>
/// Gets the configuration.
/// </summary>
GameConfiguration Configuration { get; }
/// <summary>
/// Gets the experience table. Index is the player level, value the needed experience to reach that level.
/// </summary>
long[] ExperienceTable { get; }
/// <summary>
/// Gets the master experience table. Index is the player level, value the needed experience to reach that level.
/// </summary>
long[] MasterExperienceTable { get; }
/// <summary>
/// Gets the configuration change mediator.
/// </summary>
IConfigurationChangeMediator ConfigurationChangeMediator { get; }
/// <summary>
/// Gets the plug in manager.
/// </summary>
PlugInManager PlugInManager { get; }
/// <summary>
/// Gets the feature plug ins.
/// </summary>
FeaturePlugInContainer FeaturePlugIns { get; }
/// <summary>
/// Gets the offline player manager which tracks active offline players.
/// </summary>
Offline.OfflinePlayerManager OfflinePlayerManager { get; }
/// <summary>
/// Gets the players count of the game.
/// </summary>
int PlayerCount { get; }
/// <summary>
/// Gets the logger factory.
/// </summary>
ILoggerFactory LoggerFactory { get; }
/// <summary>
/// Gets the drop generator.
/// </summary>
IDropGenerator DropGenerator { get; }
/// <summary>
/// Gets the object pool for path finders.
/// </summary>
IObjectPool<PathFinder> PathFinderPool { get; }
/// <summary>
/// Gets the duel room manager.
/// </summary>
DuelRoomManager DuelRoomManager { get; }
/// <summary>
/// Gets the state of the active self defenses. The datetime holds the timestamp when self-defense ends.
/// </summary>
ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; }
/// <summary>
/// Gets the party manager which handles party creation and persistence.
/// </summary>
IPartyManager PartyManager { get; }
/// <summary>
/// Gets the initialized maps which are hosted on this context.
/// </summary>
ValueTask<IEnumerable<GameMap>> GetMapsAsync();
/// <summary>
/// Gets the players.
/// </summary>
ValueTask<IList<Player>> GetPlayersAsync();
/// <summary>
/// Adds the player to the game.
/// </summary>
/// <param name="player">The player.</param>
ValueTask AddPlayerAsync(Player player);
/// <summary>
/// Removes the player from the game.
/// </summary>
/// <param name="player">The player.</param>
ValueTask RemovePlayerAsync(Player player);
/// <summary>
/// Gets the maps which is meant to be hosted by the game.
/// </summary>
/// <param name="mapId">The map identifier.</param>
/// <param name="createIfNotExists">If set to <c>true</c>, the map is created if it doesn't exist yet.</param>
/// <returns>
/// The hosted GameMap instance.
/// </returns>
ValueTask<GameMap?> GetMapAsync(ushort mapId, bool createIfNotExists = true);
/// <summary>
/// Gets the mini game map which is meant to be hosted by the game.
/// </summary>
/// <param name="miniGameDefinition">The mini game definition.</param>
/// <param name="requester">The requesting player.</param>
/// <returns>
/// The state of the mini game which contains the hosted GameMap instance.
/// </returns>
ValueTask<MiniGameContext> GetMiniGameAsync(MiniGameDefinition miniGameDefinition, Player requester);
/// <summary>
/// Removes the mini game instance from the context.
/// </summary>
/// <param name="miniGameContext">The context of the mini game.</param>
ValueTask RemoveMiniGameAsync(MiniGameContext miniGameContext);
/// <summary>
/// Gets the player object by character name.
/// </summary>
/// <param name="name">The character name.</param>
/// <returns>The player object.</returns>
Player? GetPlayerByCharacterName(string name);
/// <summary>
/// Sends a global message to all players of the game with the specified message type.
/// </summary>
/// <param name="message">The message.</param>
/// <param name="messageType">Type of the message.</param>
ValueTask SendGlobalMessageAsync(string message, MessageType messageType);
/// <summary>
/// Sends a global message to all players of the game with the specified message type.
/// </summary>
/// <param name="messageType">Type of the message.</param>
/// <param name="messageKey">The message resource key.</param>
/// <param name="arguments">The parameters for the message.</param>
ValueTask ShowGlobalLocalizedMessageAsync(MessageType messageType, string messageKey, params object?[] arguments);
/// <summary>
/// Sends a global chat message to all players of the game with the specified message type.
/// </summary>
/// <param name="sender">The sender.</param>
/// <param name="message">The message.</param>
/// <param name="messageType">Type of the message.</param>
ValueTask SendGlobalChatMessageAsync(string sender, string message, ChatMessageType messageType);
/// <summary>
/// Sends a golden global notification to all players of the game.
/// </summary>
/// <param name="message">The message.</param>
ValueTask SendGlobalNotificationAsync(string message);
/// <summary>
/// Executes an action for each player.
/// </summary>
/// <param name="action">The action which is executed.</param>
/// <remarks>
/// Please avoid doing actions which may lead to the connected-state of the players.
/// </remarks>
ValueTask ForEachPlayerAsync(Func<Player, Task> action);
}

View File

@@ -0,0 +1,110 @@
// <copyright file="IGameServerContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Interfaces;
/// <summary>
/// The context of a game server.
/// </summary>
public interface IGameServerContext : IGameContext
{
/// <summary>
/// Occurs when a guild has been deleted.
/// </summary>
event EventHandler<GuildEventArgs>? GuildDeleted;
/// <summary>
/// Occurs when a guild alliance has been changed.
/// </summary>
event EventHandler<GuildEventArgs>? GuildChanged;
/// <summary>
/// Gets the identifier of the server.
/// </summary>
byte Id { get; }
/// <summary>
/// Gets the guild server.
/// </summary>
IGuildServer GuildServer { get; }
/// <summary>
/// Gets the login server.
/// </summary>
ILoginServer LoginServer { get; }
/// <summary>
/// Gets the friend server.
/// </summary>
IFriendServer FriendServer { get; }
/// <summary>
/// Gets the message publisher.
/// </summary>
IEventPublisher EventPublisher { get; }
/// <summary>
/// Gets the server configuration.
/// </summary>
GameServerConfiguration ServerConfiguration { get; }
/// <summary>
/// Refreshes the guild information.
/// </summary>
/// <param name="guildId">The guild identifier.</param>
ValueTask RefreshGuildInfoAsync(uint guildId);
/// <summary>
/// Executes an action for each player of the guild.
/// </summary>
/// <param name="guildId">The guild id.</param>
/// <param name="action">The action which should be executed.</param>
ValueTask ForEachGuildPlayerAsync(uint guildId, Func<Player, Task> action);
/// <summary>
/// Executes an action for each player of the alliance of the guild.
/// </summary>
/// <param name="guildId">The guild id.</param>
/// <param name="action">The action which should be executed.</param>
ValueTask ForEachAlliancePlayerAsync(uint guildId, Func<Player, Task> action);
/// <summary>
/// Registers a guild member to the game, e.g. after a player entered a guild.
/// </summary>
/// <param name="guildMember">The guild member.</param>
ValueTask RegisterGuildMemberAsync(Player guildMember);
/// <summary>
/// Unregisters a guild member from the game, e.g. after a player left the game or the guild.
/// </summary>
/// <param name="guildMember">The guild member.</param>
ValueTask UnregisterGuildMemberAsync(Player guildMember);
/// <summary>
/// Removes a whole guild, usually after it has been disbanded.
/// </summary>
/// <param name="guildId">The id of the guild.</param>
ValueTask RemoveGuildAsync(uint guildId);
/// <summary>
/// Updates the cached rival guild pairs when the hostility state between two guilds changes.
/// </summary>
/// <param name="guildIdA">The first guild identifier.</param>
/// <param name="allianceGuildIdsA">All guild IDs in guild A's alliance.</param>
/// <param name="guildIdB">The second guild identifier.</param>
/// <param name="allianceGuildIdsB">All guild IDs in guild B's alliance.</param>
/// <param name="created"><c>true</c> if the hostility was created; <c>false</c> if it was removed.</param>
void UpdateGuildHostility(uint guildIdA, IReadOnlyList<uint> allianceGuildIdsA, uint guildIdB, IReadOnlyList<uint> allianceGuildIdsB, bool created);
/// <summary>
/// Determines whether two guilds are rivals (hostile to each other).
/// This uses a local cache and does not call the guild server.
/// </summary>
/// <param name="guild1Id">The first guild identifier.</param>
/// <param name="guild2Id">The second guild identifier.</param>
/// <returns><c>true</c> if the guilds are rivals; <c>false</c> otherwise.</returns>
bool AreGuildsRival(uint guild1Id, uint guild2Id);
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IGameServerContextProvider.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for an implementation which provides an <see cref="IGameServerContext"/>.
/// </summary>
public interface IGameServerContextProvider
{
/// <summary>
/// Gets the game server context.
/// </summary>
IGameServerContext Context { get; }
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IIdentifiable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for a identifiable object.
/// </summary>
public interface IIdentifiable
{
/// <summary>
/// Gets the identifier.
/// </summary>
ushort Id { get; }
}

View File

@@ -0,0 +1,33 @@
// <copyright file="IItemPowerUpFactory.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// A Factory for power ups which are provided by equipped items.
/// Each power up has to be created individually for a specific player, because some depend on the attributes of the player.
/// </summary>
public interface IItemPowerUpFactory
{
/// <summary>
/// Gets the power ups of an individual item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="attributeSystem">The attribute system of the player who equipped the item.</param>
/// <returns>The created power ups.</returns>
IEnumerable<PowerUpWrapper> GetPowerUps(Item item, AttributeSystem attributeSystem);
/// <summary>
/// Gets the set power ups, which are created for existing <see cref="ItemSetGroup"/>s in the equipped items.
/// </summary>
/// <param name="equippedItems">The equipped items.</param>
/// <param name="attributeSystem">The attribute system of the player who equipped the items.</param>
/// <param name="gameConfiguration">The game configuration.</param>
/// <returns>The created set power ups.</returns>
IEnumerable<PowerUpWrapper> GetSetPowerUps(IEnumerable<Item> equippedItems, AttributeSystem attributeSystem, GameConfiguration gameConfiguration);
}

View File

@@ -0,0 +1,43 @@
// <copyright file="ILocateable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Interface for an object which has a location on a map.
/// </summary>
public interface ILocateable : IIdentifiable
{
/// <summary>
/// Gets the current map on which the object currently is.
/// </summary>
GameMap? CurrentMap { get; }
/// <summary>
/// Gets or sets the coordinates on the map.
/// </summary>
Point Position { get; set; }
}
/// <summary>
/// Interface for objects which have bucket information.
/// </summary>
public interface IHasBucketInformation
{
/// <summary>
/// Gets or sets the current bucket where this instance currently moves at.
/// This is helpful for other objects to determine if the observation should
/// be continued or not.
/// </summary>
Bucket<ILocateable>? NewBucket { get; set; }
/// <summary>
/// Gets or sets the bucket where this instance currently moves away.
/// This is helpful for other objects to determine if the observation should
/// be continued or not.
/// </summary>
Bucket<ILocateable>? OldBucket { get; set; }
}

View File

@@ -0,0 +1,29 @@
// <copyright file="ILoggerOwner.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for an object which has a <see cref="ILogger"/>.
/// </summary>
public interface ILoggerOwner
{
/// <summary>
/// Gets the logger of this instance.
/// </summary>
ILogger Logger { get; }
}
/// <summary>
/// Interface for an object which has a <see cref="ILogger" />.
/// </summary>
/// <typeparam name="T">The type of the implementing class.</typeparam>
public interface ILoggerOwner<out T> : ILoggerOwner
where T : class
{
/// <summary>
/// Gets the logger of this instance.
/// </summary>
new ILogger<T> Logger { get; }
}

View File

@@ -0,0 +1,59 @@
// <copyright file="IMapInitializer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.NPC;
/// <summary>
/// An interface for a map initializer which is responsible to create new instances of <see cref="GameMap"/>s
/// and it's initialization.
/// </summary>
public interface IMapInitializer
{
/// <summary>
/// Creates a new game map instance of the specified game map number.
/// </summary>
/// <param name="mapNumber">The map number.</param>
/// <returns>The new game map instance.</returns>
GameMap? CreateGameMap(ushort mapNumber);
/// <summary>
/// Creates a new game map instance with the specified definition.
/// </summary>
/// <param name="mapDefinition">The map definition.</param>
/// <returns>The new game map instance.</returns>
GameMap CreateGameMap(GameMapDefinition mapDefinition);
/// <summary>
/// Initializes the state of the previously created game map (e.g. by creating NPC instances).
/// </summary>
/// <param name="createdMap">The created map.</param>
ValueTask InitializeStateAsync(GameMap createdMap);
/// <summary>
/// Initializes the spawn on the map.
/// </summary>
/// <param name="spawnIndex">The spawn index.</param>
/// <param name="gameMap">The game map on which the spawn should be initialized.</param>
/// <param name="spawnArea">The spawn area.</param>
/// <param name="eventStateProvider">The event state provider.</param>
/// <param name="dropGenerator">The drop generator.</param>
ValueTask<NonPlayerCharacter?> InitializeSpawnAsync(int spawnIndex, GameMap gameMap, MonsterSpawnArea spawnArea, IEventStateProvider? eventStateProvider = null, IDropGenerator? dropGenerator = null);
/// <summary>
/// Initializes the event NPCs of the previously created game map.
/// </summary>
/// <param name="createdMap">The created map.</param>
/// <param name="eventStateProvider">The event state provider.</param>
ValueTask InitializeNpcsOnEventStartAsync(GameMap createdMap, IEventStateProvider eventStateProvider);
/// <summary>
/// Initializes the event NPCs of the previously created game map after the spawn waves started.
/// </summary>
/// <param name="createdMap">The created map.</param>
/// <param name="eventStateProvider">The event state provider.</param>
/// <param name="waveNumber">The number of the started spawn wave.</param>
ValueTask InitializeNpcsOnWaveStartAsync(GameMap createdMap, IEventStateProvider eventStateProvider, byte waveNumber);
}

19
src/GameLogic/IMovable.cs Normal file
View File

@@ -0,0 +1,19 @@
// <copyright file="IMovable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Interface for an object which supports to be moved on a map.
/// </summary>
public interface IMovable
{
/// <summary>
/// Moves the object to the specified target coordinates.
/// </summary>
/// <param name="target">The target coordinates.</param>
ValueTask MoveAsync(Point target);
}

View File

@@ -0,0 +1,52 @@
// <copyright file="INpcIntelligence.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Interface of a non-player-character artificial intelligence.
/// </summary>
public interface INpcIntelligence
{
/// <summary>
/// Gets or sets the monster which this AI is controlling.
/// </summary>
NonPlayerCharacter Npc { get; set; }
/// <summary>
/// Gets a value indicating whether this instance can walk on safezone.
/// </summary>
/// <value>
/// <c>true</c> if this instance can walk on safezone; otherwise, <c>false</c>.
/// </value>
bool CanWalkOnSafezone { get; }
/// <summary>
/// Registers a hit from an attacker.
/// </summary>
/// <param name="attacker">The attacker.</param>
void RegisterHit(IAttacker attacker);
/// <summary>
/// Starts the actions.
/// </summary>
void Start();
/// <summary>
/// Pauses the actions.
/// </summary>
void Pause();
/// <summary>
/// Determines whether the monster can walk on the specified target.
/// </summary>
/// <param name="target">The target.</param>
/// <returns>
/// <c>true</c> if this instance can walk on the specified target; otherwise, <c>false</c>.
/// </returns>
bool CanWalkOn(Point target);
}

View File

@@ -0,0 +1,30 @@
// <copyright file="IObjectPool.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
/// <summary>
/// Interface for an object pool which provides an async get method which allows
/// to postpone the get until the next object is available.
/// </summary>
/// <typeparam name="T">The type which should be pooled.</typeparam>
public interface IObjectPool<T> : IDisposable
where T : class
{
/// <summary>
/// Gets an object from the pool if one is available, otherwise creates one
/// or waits until one is available.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A <typeparamref name="T" />.</returns>
ValueTask<T> GetAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Return an object to the pool.
/// </summary>
/// <param name="obj">The object to add to the pool.</param>
void Return(T obj);
}

View File

@@ -0,0 +1,35 @@
// <copyright file="IObservable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using Nito.AsyncEx;
/// <summary>
/// Interface for an observable object.
/// </summary>
public interface IObservable
{
/// <summary>
/// Gets the observers.
/// </summary>
ISet<IWorldObserver> Observers { get; }
/// <summary>
/// Gets the lock for <see cref="Observers"/>.
/// </summary>
AsyncReaderWriterLock ObserverLock { get; }
/// <summary>
/// Adds the observer.
/// </summary>
/// <param name="observer">The observer.</param>
ValueTask AddObserverAsync(IWorldObserver observer);
/// <summary>
/// Removes the observer.
/// </summary>
/// <param name="observer">The observer.</param>
ValueTask RemoveObserverAsync(IWorldObserver observer);
}

View File

@@ -0,0 +1,39 @@
// <copyright file="IPartyManager.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Manages party creation and tracks party membership for member reconnection.
/// </summary>
public interface IPartyManager
{
/// <summary>
/// Creates a new party with the configured maximum party size.
/// </summary>
/// <returns>The newly created party.</returns>
Party CreateParty();
/// <summary>
/// Called when a party member reconnects. Restores the live player into their previous party,
/// replacing the <see cref="OfflinePartyMember"/> snapshot that was created on disconnect.
/// </summary>
/// <param name="member">The reconnected member.</param>
ValueTask OnMemberReconnectedAsync(IPartyMember member);
/// <summary>
/// Registers that a character belongs to a party. Called by <see cref="Party"/> internally
/// when members are added, replaced, or removed.
/// </summary>
/// <param name="characterName">The character name.</param>
/// <param name="party">The party.</param>
internal void TrackMembership(string characterName, Party party);
/// <summary>
/// Removes the party tracking for a character. Called by <see cref="Party"/> internally
/// when members leave or are replaced.
/// </summary>
/// <param name="characterName">The character name.</param>
internal void UntrackMembership(string characterName);
}

View File

@@ -0,0 +1,41 @@
// <copyright file="IPartyMember.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The interface for a party member.
/// </summary>
public interface IPartyMember : IWorldObserver, IObservable, IIdentifiable, ILocateable
{
/// <summary>
/// Gets or sets the current party of the player.
/// </summary>
Party? Party { get; set; }
/// <summary>
/// Gets or sets the last party requester.
/// </summary>
IPartyMember? LastPartyRequester { get; set; }
/// <summary>
/// Gets the maximum health.
/// </summary>
uint MaximumHealth { get; }
/// <summary>
/// Gets the current health.
/// </summary>
uint CurrentHealth { get; }
/// <summary>
/// Gets the name.
/// </summary>
string Name { get; }
/// <summary>
/// Gets a value indicating whether the member is currently connected to the game.
/// </summary>
bool IsConnected { get; }
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IPlayerSurrogate.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Surrogate of a player.
/// </summary>
public interface IPlayerSurrogate
{
/// <summary>
/// Gets the owner of this instance.
/// </summary>
Player Owner { get; }
}

View File

@@ -0,0 +1,69 @@
// <copyright file="IRandomizer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Description of IRandomizer.
/// </summary>
public interface IRandomizer
{
/// <summary>
/// Returns the next random boolean value.
/// </summary>
/// <returns>The next random boolean value.</returns>
bool NextRandomBool();
/// <summary>
/// Returns the next random boolean value, with a chance of <paramref name="percent"/> of being true.
/// </summary>
/// <param name="percent">The percent of the chance of being true.</param>
/// <returns>The next random boolean value.</returns>
bool NextRandomBool(int percent);
/// <summary>
/// Returns the next random boolean value, with a <paramref name="chance"/> of <paramref name="basis"/> of being true.
/// </summary>
/// <param name="chance">The chance of <paramref name="basis"/> of being true.</param>
/// <param name="basis">The basis.</param>
/// <returns>The next random boolean value.</returns>
bool NextRandomBool(int chance, int basis);
/// <summary>
/// Returns the next random boolean value, with the <paramref name="chance"/> of being true.
/// </summary>
/// <param name="chance">The chance of being true.</param>
/// <returns>The next random boolean value.</returns>
bool NextRandomBool(double chance);
/// <summary>
/// The next random integer.
/// </summary>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The next random integer between <paramref name="min"/> and <paramref name="max"/>.</returns>
int NextInt(int min, int max);
/// <summary>
/// The next random integer.
/// </summary>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The next random integer between <paramref name="min"/> and <paramref name="max"/>.</returns>
int NextInt(uint min, uint max);
/// <summary>
/// The next random unsigned integer.
/// </summary>
/// <param name="min">The minimum value.</param>
/// <param name="max">The maximum value.</param>
/// <returns>The next random integer between <paramref name="min"/> and <paramref name="max"/>.</returns>
uint NextUInt(uint min, uint max);
/// <summary>
/// The next random double between 0 and 1.
/// </summary>
/// <returns>The next random integer between 0 and 1.</returns>
double NextDouble();
}

View File

@@ -0,0 +1,16 @@
// <copyright file="IRotatable.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for a rotatable class. An instance implementing this interface can be rotated on its game map.
/// </summary>
public interface IRotatable
{
/// <summary>
/// Gets or sets the rotation.
/// </summary>
Direction Rotation { get; set; }
}

View File

@@ -0,0 +1,48 @@
// <copyright file="ISkillList.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for a skill list of a character.
/// </summary>
public interface ISkillList
{
/// <summary>
/// Gets the skills.
/// </summary>
IEnumerable<SkillEntry> Skills { get; }
/// <summary>
/// Gets the number of skills in the skill list.
/// </summary>
byte SkillCount { get; }
/// <summary>
/// Gets the skill with the specified id.
/// </summary>
/// <param name="skillId">The skill identifier.</param>
/// <returns>The skill with the specified id.</returns>
SkillEntry? GetSkill(ushort skillId);
/// <summary>
/// Adds the learned skill.
/// </summary>
/// <param name="skill">The skill.</param>
ValueTask AddLearnedSkillAsync(Skill skill);
/// <summary>
/// Removes the item skill.
/// </summary>
/// <param name="skillId">The skill identifier.</param>
/// <returns>The success of removing the skill.</returns>
ValueTask<bool> RemoveItemSkillAsync(ushort skillId);
/// <summary>
/// Determines whether the list contains the specified skill of the specified id.
/// </summary>
/// <param name="skillId">The skill identifier.</param>
/// <returns><c>True</c>, if the skill with the specified id is contained in this list; Otherwise, <c>false</c>.</returns>
bool ContainsSkill(ushort skillId);
}

237
src/GameLogic/IStorage.cs Normal file
View File

@@ -0,0 +1,237 @@
// <copyright file="IStorage.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
/// <summary>
/// The types of item storages.
/// </summary>
public enum Storages
{
/// <summary>
/// The inventory storage.
/// </summary>
Inventory,
/// <summary>
/// The trade storage.
/// </summary>
Trade,
/// <summary>
/// The vault storage.
/// </summary>
Vault,
/// <summary>
/// The chaos machine storage.
/// </summary>
ChaosMachine,
/// <summary>
/// The personal store storage.
/// </summary>
PersonalStore,
/// <summary>
/// The pet trainer storage.
/// </summary>
PetTrainer,
/// <summary>
/// The storage of the refinery of the elphis npc.
/// </summary>
Refinery,
/// <summary>
/// The storage of the smelting dialog of the osbourne npc.
/// </summary>
Smelting,
/// <summary>
/// The storage of the item restore dialog of the jerridon npc.
/// </summary>
ItemRestore,
/// <summary>
/// The storage of the chaos card master dialog.
/// </summary>
ChaosCardMaster,
/// <summary>
/// The storage of the cherry blossom spirit dialog.
/// </summary>
CherryBlossomSpirit,
/// <summary>
/// The storage of the seed crafting dialog.
/// </summary>
SeedCrafting,
/// <summary>
/// The storage of the seed sphere crafting dialog.
/// </summary>
SeedSphereCrafting,
/// <summary>
/// The storage of the seed mount crafting dialog.
/// </summary>
SeedMountCrafting,
/// <summary>
/// The storage of the seed unmount crafting dialog.
/// </summary>
SeedUnmountCrafting,
}
/// <summary>
/// An object which handles the storage with its logic.
/// </summary>
public interface IStorage
{
/// <summary>
/// Gets the underlying item storage.
/// </summary>
ItemStorage ItemStorage { get; }
/// <summary>
/// Gets an enumeration of all items.
/// </summary>
IEnumerable<Item> Items { get; }
/// <summary>
/// Gets an enumeration of all free item slot indexes.
/// </summary>
IEnumerable<byte> FreeSlots { get; }
/// <summary>
/// Gets the extensions of an inventory.
/// </summary>
IEnumerable<IStorage> Extensions { get; }
/// <summary>
/// Adds the item to the storage or its <see cref="Extensions"/>.
/// </summary>
/// <param name="slot">The slot where the items should be put in.</param>
/// <param name="item">The item.</param>
/// <returns>True, if successful.</returns>
ValueTask<bool> AddItemAsync(byte slot, Item item);
/// <summary>
/// Adds the item to the next free slot of the storage or its <see cref="Extensions"/>.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>True, if successful.</returns>
ValueTask<bool> AddItemAsync(Item item);
/// <summary>
/// Tries to add money to itemStorage.
/// </summary>
/// <param name="value">The value which should be added.</param>
/// <returns><c>True</c>, if the money can be add to itemStorage; Otherwise, <c>false</c>.</returns>
bool TryAddMoney(int value);
/// <summary>
/// Tries to remove money from itemStorage.
/// </summary>
/// <param name="value">The value which should be added.</param>
/// <returns><c>True</c>, if had enought money to be remove; Otherwise, <c>false</c>.</returns>
bool TryRemoveMoney(int value);
/// <summary>
/// Returns the index of a slot in which the item would fit.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The index of a slot in which the item would fit.</returns>
byte? CheckInvSpace(Item item);
/// <summary>
/// Checks if the items of another storage will fit into this storage (including extensions), and adds them if possible.
/// </summary>
/// <param name="anotherStorage">The other storage.</param>
/// <returns>If it was successful.</returns>
/// <remarks>Helpful for the trade function where all items of the trade partner has to be added to the own inventory.</remarks>
ValueTask<bool> TryTakeAllAsync(IStorage anotherStorage);
/// <summary>
/// Gets the item from the specified slot.
/// </summary>
/// <param name="inventorySlot">The inventory slot.</param>
/// <returns>The item from the specified slot.</returns>
Item? GetItem(byte inventorySlot);
/// <summary>
/// Finds items that matches the given definition.
/// </summary>
/// <param name="definition">The item definition to be searched.</param>
/// <returns>The items with the same definition.</returns>
IEnumerable<Item> FindItemsByDefinition(ItemDefinition definition);
/// <summary>
/// Removes the item from this storage.
/// </summary>
/// <param name="item">The item which should be removed.</param>
ValueTask RemoveItemAsync(Item item);
/// <summary>
/// Clears this storage from all of its items.
/// </summary>
void Clear();
/// <summary>
/// Determines whether the slot belongs to this storage, or not.
/// Extensions are not considered here, so that this function can be used to determine to which extension
/// a slot belongs to.
/// </summary>
/// <param name="slot">The slot.</param>
/// <returns>
/// <c>true</c> if the slot belongs to this storage; otherwise, <c>false</c>.
/// </returns>
bool ContainsSlot(byte slot);
}
/// <summary>
/// Interface for the inventory storage, which may have equipped items.
/// </summary>
/// <seealso cref="MUnique.OpenMU.GameLogic.IStorage" />
public interface IInventoryStorage : IStorage
{
/// <summary>
/// Occurs when the equipped items changed.
/// </summary>
event AsyncEventHandler<ItemEventArgs> EquippedItemsChanged;
/// <summary>
/// Gets all items which are in the wearable slots.
/// </summary>
IEnumerable<Item> EquippedItems { get; }
/// <summary>
/// Gets equipped ammunition item.
/// </summary>
Item? EquippedAmmunitionItem { get; }
}
/// <summary>
/// The interface for a player shop storage. A shop can be opened by a player, and other players can buy the items of this shop.
/// </summary>
public interface IShopStorage : IStorage
{
/// <summary>
/// Gets or sets a value indicating whether the store is opened for other players.
/// </summary>
/// <value>
/// <c>true</c> if the store is opened for other players; otherwise, <c>false</c>.
/// </value>
bool StoreOpen { get; set; }
/// <summary>
/// Gets the store lock.
/// </summary>
AsyncLock StoreLock { get; }
}

View File

@@ -0,0 +1,16 @@
// <copyright file="ISupportIdUpdate.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Interface for an object which supports to set the <see cref="IIdentifiable.Id"/>.
/// </summary>
public interface ISupportIdUpdate : IIdentifiable
{
/// <summary>
/// Gets or sets the identifier.
/// </summary>
new ushort Id { get; set; }
}

View File

@@ -0,0 +1,55 @@
// <copyright file="ISupportWalk.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Interface for objects which support walking.
/// </summary>
public interface ISupportWalk : ILocateable
{
/// <summary>
/// Gets a value indicating whether this instance can walk on safezone.
/// </summary>
/// <value>
/// <c>true</c> if this instance can walk on safezone; otherwise, <c>false</c>.
/// </value>
bool CanWalkOnSafezone { get; }
/// <summary>
/// Gets a value indicating whether this instance is walking.
/// </summary>
bool IsWalking { get; }
/// <summary>
/// Gets the delay between each step. Lower delay means faster walking.
/// </summary>
TimeSpan StepDelay { get; }
/// <summary>
/// Gets the walk target coordinate.
/// </summary>
Point WalkTarget { get; }
/// <summary>
/// Gets the steps which are about to happen next by writing them into the given span.
/// </summary>
/// <param name="steps">The steps.</param>
/// <returns>The number of written steps.</returns>
ValueTask<int> GetStepsAsync(Memory<WalkingStep> steps);
/// <summary>
/// Gets the directions of the steps which are about to happen next by writing them into the given span.
/// </summary>
/// <param name="directions">The directions.</param>
/// <returns>The number of written directions.</returns>
ValueTask<int> GetDirectionsAsync(Memory<Direction> directions);
/// <summary>
/// Stops the walking.
/// </summary>
ValueTask StopWalkingAsync();
}

88
src/GameLogic/ITrader.cs Normal file
View File

@@ -0,0 +1,88 @@
// <copyright file="ITrader.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Interface of a trader.
/// </summary>
public interface ITrader : IWorldObserver
{
/// <summary>
/// Gets the character name.
/// </summary>
string Name { get; }
/// <summary>
/// Gets the traders level.
/// </summary>
int Level { get; }
/// <summary>
/// Gets or sets the current trading partner.
/// </summary>
ITrader? TradingPartner { get; set; }
/// <summary>
/// Gets or sets the money which is currently in the trade.
/// </summary>
int TradingMoney { get; set; }
/// <summary>
/// Gets or sets the short guild identifier.
/// </summary>
GuildMemberStatus? GuildStatus { get; set; }
/// <summary>
/// Gets the inventory.
/// </summary>
IInventoryStorage? Inventory { get; }
/// <summary>
/// Gets the temporary storage, which holds the items of the trade.
/// </summary>
IStorage? TemporaryStorage { get; }
/// <summary>
/// Gets or sets the backup inventory.
/// </summary>
BackupItemStorage? BackupInventory { get; set; }
/// <summary>
/// Gets or sets the available money.
/// </summary>
int Money { get; set; }
/// <summary>
/// Gets the state of the player.
/// </summary>
StateMachine PlayerState { get; }
/// <summary>
/// Gets the persistence context of the trader. It needs to be updated when a trade finishes.
/// </summary>
IPlayerContext PersistenceContext { get; }
/// <summary>
/// Gets the game context of the trader.
/// </summary>
IGameContext GameContext { get; }
/// <summary>
/// Gets a value indicating whether this instance is template player.
/// In this case, trading is not allowed.
/// </summary>
bool IsTemplatePlayer { get; }
/// <summary>
/// Saves the progress of the trader.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Success of the save operation.</returns>
ValueTask<bool> SaveProgressAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,22 @@
// <copyright file="IWorldObserver.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Interface of an world observer.
/// </summary>
public interface IWorldObserver : ILoggerOwner
{
/// <summary>
/// Gets the view plug ins.
/// </summary>
/// <value>
/// The view plug ins.
/// </value>
ICustomPlugInContainer<IViewPlugIn> ViewPlugIns { get; }
}

View File

@@ -0,0 +1,220 @@
// <copyright file="InventoryStorage.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Views.World;
using MUnique.OpenMU.PlugIns;
using static MUnique.OpenMU.DataModel.InventoryConstants;
/// <summary>
/// The storage of an inventory of a player, which also contains equippable slots. This class also manages the powerups which get created by equipped items.
/// </summary>
public class InventoryStorage : Storage, IInventoryStorage
{
private readonly IGameContext _gameContext;
private readonly Player _player;
/// <summary>
/// Initializes a new instance of the <see cref="InventoryStorage" /> class.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="context">The game context.</param>
public InventoryStorage(Player player, IGameContext context)
: base(
GetInventorySize(0),
EquippableSlotsCount,
0,
new ItemStorageAdapter(player.SelectedCharacter?.Inventory ?? throw Error.NotInitializedProperty(player, "SelectedCharacter.Inventory"), FirstEquippableItemSlotIndex, player.InventorySize))
{
this._player = player;
this.EquippedItemsChanged += async eventArgs => await this.UpdateItemsOnChangeAsync(eventArgs.Item, eventArgs.IsEquipped).ConfigureAwait(false);
this._gameContext = context;
if (player.SelectedCharacter.InventoryExtensions > 0)
{
var extensions = new List<Storage>(player.SelectedCharacter.InventoryExtensions);
var sizePerExtension = RowsOfOneExtension * RowSize;
for (int i = 0; i < player.SelectedCharacter.InventoryExtensions; i++)
{
var offset = FirstExtensionItemSlotIndex + (i * sizePerExtension);
var extension = new Storage(sizePerExtension, 0, offset, this.ItemStorage);
extensions.Add(extension);
}
this.Extensions = extensions;
}
this.InitializePowerUps();
}
/// <inheritdoc/>
public event AsyncEventHandler<ItemEventArgs>? EquippedItemsChanged;
/// <inheritdoc/>
public IEnumerable<Item> EquippedItems
{
get
{
for (int i = FirstEquippableItemSlotIndex; i <= LastEquippableItemSlotIndex; i++)
{
if (this.ItemArray[i] is not null)
{
yield return this.ItemArray[i]!;
}
}
}
}
/// <inheritdoc/>
public Item? EquippedAmmunitionItem
{
get
{
if (this.ItemArray[LeftHandSlot] is { } leftItem && (leftItem.Definition?.IsAmmunition ?? false))
{
return leftItem;
}
if (this.ItemArray[RightHandSlot] is { } rightItem && (rightItem.Definition?.IsAmmunition ?? false))
{
return rightItem;
}
return null;
}
}
/// <inheritdoc />
/// <remarks>Additionally we need to make a temporary item persistent with the context of the player.</remarks>
public override async ValueTask<bool> AddItemAsync(byte slot, Item item)
{
Item? convertedItem = null;
if (item is TemporaryItem temporaryItem)
{
convertedItem = temporaryItem.MakePersistent(this._player.PersistenceContext);
}
var success = await base.AddItemAsync(slot, convertedItem ?? item).ConfigureAwait(false);
if (!success && convertedItem != null)
{
this._player.PersistenceContext.Detach(convertedItem);
}
if (success)
{
var isEquippedItem = this.IsWearingSlot(slot);
if (isEquippedItem && this.EquippedItemsChanged is { } eventHandler)
{
await eventHandler(new ItemEventArgs(convertedItem ?? item, true)).ConfigureAwait(false);
}
}
return success;
}
/// <inheritdoc />
public override async ValueTask RemoveItemAsync(Item item)
{
var isEquippedItem = this.IsWearingSlot(item.ItemSlot);
await base.RemoveItemAsync(item).ConfigureAwait(false);
if (isEquippedItem && this.EquippedItemsChanged is { } eventHandler)
{
await eventHandler(new ItemEventArgs(item, false)).ConfigureAwait(false);
}
}
private bool IsWearingSlot(int slot)
{
return slot >= FirstEquippableItemSlotIndex && slot <= LastEquippableItemSlotIndex;
}
private async ValueTask UpdateItemsOnChangeAsync(Item item, bool isEquipped)
{
this._player.OnAppearanceChanged();
await this._player.ForEachWorldObserverAsync<IAppearanceChangedPlugIn>(
p => p.AppearanceChangedAsync(this._player, item, isEquipped),
false).ConfigureAwait(false); // in my tests it was not needed to send the appearance to the own players client.
if (this._player.Attributes is null)
{
return;
}
if (this._player.Attributes.ItemPowerUps.Remove(item, out var itemPowerUps))
{
foreach (var powerUp in itemPowerUps)
{
powerUp.Dispose();
}
}
this.UpdateSetPowerUps();
var itemAdded = this.EquippedItems.Contains(item);
if (itemAdded)
{
var factory = this._gameContext.ItemPowerUpFactory;
this._player.Attributes.ItemPowerUps.Add(item, factory.GetPowerUps(item, this._player.Attributes).ToList());
// reset player equipped ammunition amount
if (this.EquippedAmmunitionItem is { } ammoItem)
{
this._player.Attributes[Stats.AmmunitionAmount] = (float)ammoItem.Durability;
}
}
}
private void InitializePowerUps()
{
if (this._player.Attributes is null)
{
throw new InvalidOperationException("The player's AttributeSystem is not set yet.");
}
foreach (var powerUp in this._player.Attributes.ItemPowerUps.Values.SelectMany(p => p).ToList())
{
powerUp.Dispose();
}
var factory = this._gameContext?.ItemPowerUpFactory;
if (factory != null)
{
foreach (var item in this.EquippedItems)
{
this._player.Attributes.ItemPowerUps.Add(item, factory.GetPowerUps(item, this._player.Attributes).ToList());
}
this.UpdateSetPowerUps();
}
else
{
this._player.Logger.LogError("item power up factory not available during initialization of the inventory.");
}
}
private void UpdateSetPowerUps()
{
if (this._player.Attributes is null)
{
throw new InvalidOperationException("The players AttributeSystem is not set yet.");
}
if (this._player.Attributes.ItemSetPowerUps is not null)
{
foreach (var powerUp in this._player.Attributes.ItemSetPowerUps)
{
powerUp.Dispose();
}
}
var factory = this._gameContext.ItemPowerUpFactory;
this._player.Attributes.ItemSetPowerUps = factory.GetSetPowerUps(this.EquippedItems, this._player.Attributes, this._player.GameContext.Configuration).ToList();
}
}

View File

@@ -0,0 +1,166 @@
// <copyright file="ItemConstants.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// A central place to keep item identifiers, so we can keep track of them.
/// </summary>
public class ItemConstants
{
/// <summary>
/// Gets the identifier for the summon orb.
/// </summary>
public static ItemIdentifier SummonOrb => new(11, 12);
/// <summary>
/// Gets the identifier for the fruits.
/// </summary>
public static ItemIdentifier Fruits => new(15, 13);
/// <summary>
/// Gets the identifier for the alcohol.
/// </summary>
public static ItemIdentifier Alcohol => new(9, 14);
/// <summary>
/// Gets the identifier for the apple.
/// </summary>
public static ItemIdentifier Apple => new(0, 14);
/// <summary>
/// Gets the identifier for the small healing potion.
/// </summary>
public static ItemIdentifier SmallHealingPotion => new(1, 14);
/// <summary>
/// Gets the identifier for the medium healing potion.
/// </summary>
public static ItemIdentifier MediumHealingPotion => new(2, 14);
/// <summary>
/// Gets the identifier for the large healing potion.
/// </summary>
public static ItemIdentifier LargeHealingPotion => new(3, 14);
/// <summary>
/// Gets the identifier for the small mana potion.
/// </summary>
public static ItemIdentifier SmallManaPotion => new(4, 14);
/// <summary>
/// Gets the identifier for the medium mana potion.
/// </summary>
public static ItemIdentifier MediumManaPotion => new(5, 14);
/// <summary>
/// Gets the identifier for the large mana potion.
/// </summary>
public static ItemIdentifier LargeManaPotion => new(6, 14);
/// <summary>
/// Gets the identifier for the siege potion.
/// </summary>
public static ItemIdentifier SiegePotion => new(7, 14);
/// <summary>
/// Gets the identifier for the antidote.
/// </summary>
public static ItemIdentifier Antidote => new(8, 14);
/// <summary>
/// Gets the identifier for the town portal scroll.
/// </summary>
public static ItemIdentifier TownPortalScroll => new(10, 14);
/// <summary>
/// Gets the identifier for the jewel of chaos.
/// </summary>
public static ItemIdentifier JewelOfChaos => new(15, 12);
/// <summary>
/// Gets the identifier for the jewel of bless.
/// </summary>
public static ItemIdentifier JewelOfBless => new(13, 14);
/// <summary>
/// Gets the identifier for the jewel of soul.
/// </summary>
public static ItemIdentifier JewelOfSoul => new(14, 14);
/// <summary>
/// Gets the identifier for the jewel of life.
/// </summary>
public static ItemIdentifier JewelOfLife => new(16, 14);
/// <summary>
/// Gets the identifier for the jewel of creation.
/// </summary>
public static ItemIdentifier JewelOfCreation => new(22, 14);
/// <summary>
/// Gets the identifier for the jewel of guardian.
/// </summary>
public static ItemIdentifier JewelOfGuardian => new(31, 14);
/// <summary>
/// Gets the identifier for the gemstone.
/// </summary>
public static ItemIdentifier Gemstone => new(41, 14);
/// <summary>
/// Gets the identifier for the small shield potion.
/// </summary>
public static ItemIdentifier SmallShieldPotion => new(35, 14);
/// <summary>
/// Gets the identifier for the medium shield potion.
/// </summary>
public static ItemIdentifier MediumShieldPotion => new(36, 14);
/// <summary>
/// Gets the identifier for the large shield potion.
/// </summary>
public static ItemIdentifier LargeShieldPotion => new(37, 14);
/// <summary>
/// Gets the identifier for the small complex potion.
/// </summary>
public static ItemIdentifier SmallComplexPotion => new(38, 14);
/// <summary>
/// Gets the identifier for the medium complex potion.
/// </summary>
public static ItemIdentifier MediumComplexPotion => new(39, 14);
/// <summary>
/// Gets the identifier for the large complex potion.
/// </summary>
public static ItemIdentifier LargeComplexPotion => new(40, 14);
/// <summary>
/// Gets the identifier for the jewel of harmony.
/// </summary>
public static ItemIdentifier JewelOfHarmony => new(42, 14);
/// <summary>
/// Gets the identifier for the lower refine stone.
/// </summary>
public static ItemIdentifier LowerRefineStone => new(43, 14);
/// <summary>
/// Gets the identifier for the higher refine stone.
/// </summary>
public static ItemIdentifier HigherRefineStone => new(44, 14);
/// <summary>
/// Gets the identifier for the wizard's ring (group 13, number 20).
/// </summary>
public static ItemIdentifier WizardsRing => new(20, 13);
/// <summary>
/// Gets all scrolls.
/// </summary>
public static ItemIdentifier AllScrolls => new(null, 15);
}

View File

@@ -0,0 +1,33 @@
// <copyright file="ItemEventArgs.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Event args containing the involved instance of an <see cref="Item"/>.
/// </summary>
/// <seealso cref="System.EventArgs" />
public class ItemEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ItemEventArgs"/> class.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="isEquipped">Whether equipped or not.</param>
public ItemEventArgs(Item item, bool isEquipped)
{
this.Item = item;
this.IsEquipped = isEquipped;
}
/// <summary>
/// Gets the item which is involved at the event.
/// </summary>
public Item Item { get; }
/// <summary>
/// Gets a value indicating whether the item is equipped at the event.
/// </summary>
public bool IsEquipped { get; }
}

View File

@@ -0,0 +1,439 @@
// <copyright file="ItemExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Diagnostics.CodeAnalysis;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Extension methods for <see cref="Item"/>.
/// </summary>
public static class ItemExtensions
{
private const byte ShieldItemGroup = 6;
private static readonly byte[] AdditionalDurabilityPerLevel = { 0, 1, 2, 3, 4, 6, 8, 10, 12, 14, 17, 21, 26, 32, 39, 47 };
private static readonly IDictionary<AttributeDefinition, AttributeDefinition> RequirementAttributeMapping = new Dictionary<AttributeDefinition, AttributeDefinition>
{
{ Stats.TotalStrengthRequirementValue, Stats.TotalStrength },
{ Stats.TotalAgilityRequirementValue, Stats.TotalAgility },
{ Stats.TotalEnergyRequirementValue, Stats.TotalEnergy },
{ Stats.TotalVitalityRequirementValue, Stats.TotalVitality },
{ Stats.TotalLeadershipRequirementValue, Stats.TotalLeadership },
};
private static readonly IDictionary<AttributeDefinition, AttributeDefinition> RequirementReductionAttributeMapping = new Dictionary<AttributeDefinition, AttributeDefinition>
{
{ Stats.TotalStrengthRequirementValue, Stats.RequiredStrengthReduction },
{ Stats.TotalAgilityRequirementValue, Stats.RequiredAgilityReduction },
{ Stats.TotalEnergyRequirementValue, Stats.RequiredEnergyReduction },
{ Stats.TotalVitalityRequirementValue, Stats.RequiredVitalityReduction },
{ Stats.TotalLeadershipRequirementValue, Stats.RequiredLeadershipReduction },
};
/// <summary>
/// Gets the maximum durability of the item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The maximum durability of the item.</returns>
/// <remarks>
/// I think this is more like the durability which can be dropped.
/// Some items can be stacked up to 255 pieces, which increases the durability value.
/// </remarks>
public static byte GetMaximumDurabilityOfOnePiece(this Item item)
{
if (!item.IsWearable())
{
// Items which are not wearable don't have a "real" durability. If the item is stackable, durability means number of pieces in this case
return 1;
}
if (item.IsTrainablePet())
{
return 255;
}
var result = item.Definition!.Durability + AdditionalDurabilityPerLevel[item.Level];
if (item.IsAncient())
{
result += 20;
}
else if (item.IsExcellent())
{
// TODO: archangel weapons, but I guess it's not a big issue if we don't, because of their already high durability
result += 15;
}
else
{
// there are no other options which increase the durability.
// It might be nice to add the magic values above to the ItemOptionType, as data.
}
return (byte)Math.Min(byte.MaxValue, result);
}
/// <summary>
/// Determines whether this instance is ancient.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is ancient; otherwise, <c>false</c>.
/// </returns>
public static bool IsAncient(this Item item)
{
return item.ItemSetGroups.Any(itemSet => itemSet.AncientSetDiscriminator > 0);
}
/// <summary>
/// Determines whether this instance is excellent.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is excellent; otherwise, <c>false</c>.
/// </returns>
public static bool IsExcellent(this Item item)
{
return item.ItemOptions.Any(link => link.ItemOption?.OptionType == ItemOptionTypes.Excellent);
}
/// <summary>
/// Determines whether this instance is a "380 item", that is, if it can be upgraded with Jewel of Guardian.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is a "380 item"; otherwise, <c>false</c>.
/// </returns>
public static bool IsGuardian(this Item item)
{
return item.Definition!.PossibleItemOptions.Any(pio => pio.PossibleOptions
.Any(po => po.OptionType == ItemOptionTypes.GuardianOption));
}
/// <summary>
/// Determines whether this item is a defensive item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns><see langword="true"/>, if the item is defensive.</returns>
public static bool IsDefensiveItem(this Item item)
{
return InventoryConstants.IsDefenseItemSlot(item.ItemSlot) || item.IsShield();
}
/// <summary>
/// Determines whether this instance is a shield.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is a shield; otherwise, <c>false</c>.
/// </returns>
public static bool IsShield(this Item item)
{
return item.Definition?.Group == ShieldItemGroup;
}
/// <summary>
/// Determines whether this item is a jewelry (pendant or ring) item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is jewelry; otherwise, <c>false</c>.
/// </returns>
public static bool IsJewelry(this Item item)
{
return item.ItemSlot >= InventoryConstants.PendantSlot && item.ItemSlot <= InventoryConstants.Ring2Slot;
}
/// <summary>
/// Determines whether this item is an armor item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is armor; otherwise, <c>false</c>.
/// </returns>
public static bool IsArmorItem(this Item item)
{
return item.ItemSlot >= InventoryConstants.HelmSlot && item.ItemSlot <= InventoryConstants.BootsSlot;
}
/// <summary>
/// Determines whether this instance is a is weapon which deals physical damage.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="minimumDmg">The minimum physical damage of the weapon.</param>
/// <returns>
/// <c>true</c> if this instance is a weapon which deals physical damage; otherwise, <c>false</c>.
/// </returns>
public static bool IsPhysicalWeapon(this Item item, [NotNullWhen(true)] out float? minimumDmg)
{
minimumDmg = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.MinimumPhysBaseDmgByWeapon)?.BaseValue;
return minimumDmg is not null;
}
/// <summary>
/// Determines whether this instance is a weapon which increases wizardry damage.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="staffRise">The staff/sword/stick's wizardry damage rise percentage.</param>
/// <returns>
/// <c>true</c> if this instance is a weapon which increases wizardry damage; otherwise, <c>false</c>.
/// </returns>
public static bool IsWizardryWeapon(this Item item, [NotNullWhen(true)] out float? staffRise)
{
staffRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.StaffRise)?.BaseValue;
return staffRise is not null;
}
/// <summary>
/// Determines whether this instance is a scepter which increases raven damage.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="scepterRise">The scepter's pet attack rise percentage.</param>
/// <returns>
/// <c>true</c> if this instance is a scepter which increases raven damage; otherwise, <c>false</c>.
/// </returns>
public static bool IsScepter(this Item item, [NotNullWhen(true)] out float? scepterRise)
{
scepterRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.ScepterRise)?.BaseValue;
return scepterRise is not null;
}
/// <summary>
/// Determines whether this instance is a book which increases curse damage.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="bookRise">The book's curse damage rise percentage.</param>
/// <returns>
/// <c>true</c> if this instance is a book which increases curse damage; otherwise, <c>false</c>.
/// </returns>
public static bool IsBook(this Item item, [NotNullWhen(true)] out float? bookRise)
{
bookRise = item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.BookRise)?.BaseValue;
return bookRise is not null;
}
/// <summary>
/// Returns a random offensive item of the storage.
/// </summary>
/// <param name="storage">The storage.</param>
/// <returns>A randomly selected offensive item.</returns>
public static Item? GetRandomOffensiveItem(this IInventoryStorage storage)
{
var left = storage.GetItem(InventoryConstants.LeftHandSlot);
var right = storage.GetItem(InventoryConstants.RightHandSlot);
var pendant = storage.GetItem(InventoryConstants.PendantSlot);
if ((left?.Definition?.IsAmmunition ?? false)
|| left?.Definition?.Group == ShieldItemGroup)
{
left = null;
}
if ((right?.Definition?.IsAmmunition ?? false)
|| right?.Definition?.Group == ShieldItemGroup)
{
right = null;
}
var random = Rand.NextInt(3, 6);
var result = left ?? right ?? pendant;
if (result is null)
{
return null;
}
switch (random % 3)
{
case 0 when left is { }:
result = left;
break;
case 1 when right is { }:
result = right;
break;
case 2 when pendant is { }:
result = pendant;
break;
default:
// keep first available result
break;
}
return result;
}
/// <summary>
/// Gets the requirement as a tuple of an attribute and the corresponding value.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="requirement">The requirement.</param>
/// <returns>The requirement as a tuple of an attribute and the corresponding value.</returns>
/// <remarks>
/// Some requirements are depending on item level, drop level and item options.
/// </remarks>
public static (AttributeDefinition Attr, int Value) GetRequirement(this Item item, AttributeRequirement requirement)
{
requirement.ThrowNotInitializedProperty(requirement.Attribute is null, nameof(requirement.Attribute));
if (RequirementAttributeMapping.TryGetValue(requirement.Attribute, out var totalAttribute))
{
if (!item.IsWearable())
{
return (totalAttribute, requirement.MinimumValue);
}
var multiplier = 3;
if (totalAttribute == Stats.TotalEnergy)
{
multiplier = 4;
// Summoner Books are calculated differently. They are in group 5 (staffs) and are the only items in the group which can have skill.
if (item.Definition?.Skill != null && item.Definition.Group == 5)
{
return (totalAttribute, item.CalculateBookEnergyRequirement(requirement.MinimumValue));
}
}
var value = item.CalculateRequirement(requirement.MinimumValue, multiplier);
if (value > 0 && totalAttribute == Stats.TotalStrength)
{
var itemOption = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option);
if (itemOption != null)
{
value += itemOption.Level * 4;
}
}
if (RequirementReductionAttributeMapping.TryGetValue(requirement.Attribute, out var reductionAttribute)
&& item.ItemOptions.FirstOrDefault(o =>
o.ItemOption?.PowerUpDefinition?.TargetAttribute == reductionAttribute
|| (o.ItemOption?.LevelDependentOptions.Any(l => l.PowerUpDefinition?.TargetAttribute == reductionAttribute) ?? false))
is { } reductionOption)
{
var optionOfLevelPowerUp = reductionOption.ItemOption!.LevelDependentOptions
.FirstOrDefault(o => o.Level == reductionOption.Level)?.PowerUpDefinition
?? reductionOption.ItemOption.PowerUpDefinition;
if (optionOfLevelPowerUp?.Boost?.ConstantValue is { } reduction)
{
value -= (int)reduction.Value;
}
}
return (totalAttribute, value);
}
return (requirement.Attribute, requirement.MinimumValue);
}
/// <summary>
/// Gets the item data which is relevant for the visual appearance of an item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The item data which is relevant for the visual appearance of an item.</returns>
public static ItemAppearance GetAppearance(this Item item)
{
var appearance = new TemporaryItemAppearance
{
Definition = item.Definition,
ItemSlot = item.ItemSlot,
Level = item.Level,
};
item.ItemOptions
.Where(option => option.ItemOption?.OptionType is not null && option.ItemOption.OptionType.IsVisible)
.Select(option => option.ItemOption!.OptionType!)
.Distinct()
.ForEach(appearance.VisibleOptions.Add);
if (item.IsAncient())
{
// 1. The ancient option is not included in the item.ItemOptions.
// 2. The bonus option is not always existing for ancient items. And it's not marked as visible.
// -> we check it based on the item set group.
appearance.VisibleOptions.Add(ItemOptionTypes.AncientOption);
}
return appearance;
}
/// <summary>
/// Creates a persistent instance of the given <see cref="ItemAppearance"/> and returns it.
/// </summary>
/// <param name="itemAppearance">The item appearance.</param>
/// <param name="persistenceContext">The persistence context where the object should be added.</param>
/// <param name="gameConfiguration">The game configuration.</param>
/// <returns>A persistent instance of the given <see cref="ItemAppearance"/>.</returns>
public static ItemAppearance MakePersistent(this ItemAppearance itemAppearance, IContext persistenceContext, GameConfiguration gameConfiguration)
{
var persistent = persistenceContext.CreateNew<ItemAppearance>();
persistent.ItemSlot = itemAppearance.ItemSlot;
persistent.Definition = itemAppearance.Definition;
persistent.Level = itemAppearance.Level;
itemAppearance.VisibleOptions.Distinct().ForEach(o => persistent.VisibleOptions.Add(gameConfiguration.ItemOptionTypes.First(iot => iot.Equals(o))));
return persistent;
}
/// <summary>
/// Calculates the drop level.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="isAncient">A value indicating whether the item is ancient.</param>
/// <param name="isExcellent">A value indicating whether the item is excellent.</param>
/// <param name="itemLevel">The item level.</param>
/// <returns>The calculated drop level.</returns>
public static int CalculateDropLevel(this ItemDefinition item, bool isAncient, bool isExcellent, int itemLevel)
{
int dropLevel = item.DropLevel;
if (isAncient)
{
dropLevel += 30;
}
else if (isExcellent)
{
dropLevel += 25;
}
else
{
// nothing to add
}
dropLevel += 3 * itemLevel;
return dropLevel;
}
/// <summary>
/// Calculates the drop level of the item in the current state.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The calculated drop level.</returns>
public static int CalculateDropLevel(this Item item)
{
return item.Definition?.CalculateDropLevel(item.IsAncient(), item.IsExcellent(), item.Level) ?? 0;
}
private static int CalculateRequirement(this Item item, int requirementValue, int multiplier)
{
if (requirementValue == 0)
{
return 0;
}
var dropLevel = item.Definition!.CalculateDropLevel(item.IsAncient(), item.IsExcellent(), item.Level);
return (multiplier * dropLevel * requirementValue / 100) + 20;
}
private static int CalculateBookEnergyRequirement(this Item item, int energyRequirementValue)
{
var dropLevel = item.Definition!.CalculateDropLevel(item.IsAncient(), item.IsExcellent(), 0);
return (((energyRequirementValue * (dropLevel + item.Level)) * 3) / 100) + 20;
}
private sealed class TemporaryItemAppearance : ItemAppearance
{
public override ICollection<ItemOptionType> VisibleOptions => base.VisibleOptions ??= new List<ItemOptionType>();
}
}

View File

@@ -0,0 +1,10 @@
// <copyright file="ItemIdentifier.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// The identifier for an item, including number and group.
/// </summary>
public record struct ItemIdentifier(short? Number, byte Group);

View File

@@ -0,0 +1,438 @@
// <copyright file="ItemPowerUpFactory.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Attributes;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.Persistence;
/// <summary>
/// The implementation of the item power up factory.
/// </summary>
public class ItemPowerUpFactory : IItemPowerUpFactory
{
private readonly ILogger<ItemPowerUpFactory> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ItemPowerUpFactory"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
public ItemPowerUpFactory(ILogger<ItemPowerUpFactory> logger)
{
this._logger = logger;
}
/// <inheritdoc/>
public IEnumerable<PowerUpWrapper> GetPowerUps(Item item, AttributeSystem attributeHolder)
{
if (item.Definition is null)
{
this._logger.LogWarning("Item of slot {itemSlot} ({itemId}) has no definition.", item.ItemSlot, item.GetId());
yield break;
}
if (item.Durability <= 0)
{
yield break;
}
if (item.ItemSlot < InventoryConstants.FirstEquippableItemSlotIndex || item.ItemSlot > InventoryConstants.LastEquippableItemSlotIndex)
{
yield break;
}
var isRightWieldWeapon = item.ItemSlot == InventoryConstants.RightHandSlot
&& item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount || pu.TargetAttribute == Stats.IsOneHandedStaffEquipped);
AttributeDefinition? targetAttribute = null;
foreach (var attribute in item.Definition.BasePowerUpAttributes)
{
if (isRightWieldWeapon)
{
if (attribute.TargetAttribute == Stats.StaffRise)
{
continue;
}
else if (attribute.TargetAttribute == Stats.MinimumPhysBaseDmgByWeapon)
{
targetAttribute = Stats.MinPhysBaseDmgByRightWeapon;
}
else if (attribute.TargetAttribute == Stats.MaximumPhysBaseDmgByWeapon)
{
targetAttribute = Stats.MaxPhysBaseDmgByRightWeapon;
}
else
{
targetAttribute = null;
}
}
foreach (var powerUp in this.GetBasePowerUpWrappers(item, attributeHolder, attribute, targetAttribute))
{
yield return powerUp;
}
}
foreach (var powerUp in this.GetPowerUpsOfItemOptions(item, attributeHolder))
{
yield return powerUp;
}
if (this.GetPetLevel(item, attributeHolder) is { } petLevel)
{
yield return petLevel;
}
}
/// <inheritdoc/>
public IEnumerable<PowerUpWrapper> GetSetPowerUps(
IEnumerable<Item> equippedItems,
AttributeSystem attributeHolder,
GameConfiguration gameConfiguration)
{
var activeItems = equippedItems
.Where(i => i.Durability > 0)
.ToList();
var itemGroups = activeItems
.SelectMany(i => i.ItemSetGroups)
.Select(i => i.ItemSetGroup!)
.Distinct();
var result = Enumerable.Empty<PowerUpDefinition>();
var alwaysGroups = activeItems.SelectMany(i => i.Definition!.PossibleItemSetGroups).Where(i => i.AlwaysApplies).Distinct();
foreach (var group in alwaysGroups.Concat(itemGroups).Distinct())
{
var itemsOfGroup = activeItems.Where(i =>
((group.AlwaysApplies && i.Definition!.PossibleItemSetGroups.Contains(group))
|| i.ItemSetGroups.Any(ios => ios.ItemSetGroup == group))
&& (group.SetLevel == 0 || i.Level >= group.SetLevel));
var setMustBeComplete = group.MinimumItemCount == group.Items.Count;
if (group.SetLevel > 0 && setMustBeComplete && itemsOfGroup.All(i => i.Level > group.SetLevel))
{
// When all items are of higher level and the set bonus is applied when all items are there, another item set group will take care.
// This should prevent that for example set bonus defense is applied multiple times.
continue;
}
if (group.Options is not { } options)
{
this._logger.LogWarning("Options of set {group} is not initialized", group);
continue;
}
var itemCount = group.CountDistinct ? itemsOfGroup.Select(item => item.Definition).Distinct().Count() : itemsOfGroup.Count();
var setIsComplete = itemCount == group.Items.Count;
if (setIsComplete)
{
// Take all options when the set is complete
result = result.Concat(
options.PossibleOptions
.Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition))));
continue;
}
if (itemCount >= group.MinimumItemCount)
{
// Take the first n-1 options
result = result.Concat(options.PossibleOptions.OrderBy(o => o.Number)
.Take(itemCount - 1)
.Select(o => o.PowerUpDefinition ?? throw Error.NotInitializedProperty(o, nameof(o.PowerUpDefinition))));
}
}
result = result.Concat(this.GetOptionCombinationBonus(activeItems, gameConfiguration));
return result.SelectMany(p => PowerUpWrapper.CreateByPowerUpDefinition(p, attributeHolder));
}
private IEnumerable<PowerUpDefinition> GetOptionCombinationBonus(IEnumerable<Item> activeItems, GameConfiguration gameConfiguration)
{
if (gameConfiguration?.ItemOptionCombinationBonuses is null
|| gameConfiguration.ItemOptionCombinationBonuses.Count == 0)
{
yield break;
}
var activeItemOptions = activeItems.SelectMany(i => i.ItemOptions.Select(o => o.ItemOption ?? throw Error.NotInitializedProperty(o, nameof(o.ItemOption)))).ToList();
foreach (var combinationBonus in gameConfiguration.ItemOptionCombinationBonuses.Where(c => c.Bonus is { }))
{
var remainingOptions = activeItemOptions.ToList<ItemOption>();
while (this.AreRequiredOptionsFound(combinationBonus, remainingOptions))
{
if (combinationBonus.Bonus is not null)
{
yield return combinationBonus.Bonus;
}
else
{
this._logger.LogWarning("Bonus of ItemOptionCombinationBonus '{combinationBonusName}' is not initialized, id: {id}", combinationBonus.Description, combinationBonus.GetId());
}
if (!combinationBonus.AppliesMultipleTimes)
{
break;
}
}
}
}
private bool AreRequiredOptionsFound(ItemOptionCombinationBonus bonus, IList<ItemOption> itemOptions)
{
var allMatches = new List<ItemOption>();
foreach (var requirement in bonus.Requirements)
{
var matches = itemOptions
.Where(o => o.OptionType is not null)
.Where(o => o.OptionType == requirement.OptionType && o.SubOptionType == requirement.SubOptionType)
.Take(requirement.MinimumCount)
.ToList();
if (matches.Count < requirement.MinimumCount)
{
return false;
}
allMatches.AddRange(matches);
}
allMatches.ForEach(o => itemOptions.Remove(o));
return true;
}
private IEnumerable<PowerUpWrapper> GetBasePowerUpWrappers(Item item, AttributeSystem attributeHolder, ItemBasePowerUpDefinition attribute, AttributeDefinition? targetAttribute = null)
{
attribute.ThrowNotInitializedProperty(attribute.BaseValueElement is null, nameof(attribute.BaseValueElement));
attribute.ThrowNotInitializedProperty(attribute.TargetAttribute is null, nameof(attribute.TargetAttribute));
var levelBonusElmt = (attribute.BonusPerLevelTable?.BonusPerLevel ?? Enumerable.Empty<LevelBonus>())
.FirstOrDefault(bonus => bonus.Level == item.Level)?
.GetAdditionalValueElement(attribute.AggregateType);
if (levelBonusElmt is null)
{
yield return new PowerUpWrapper(attribute.BaseValueElement, targetAttribute ?? attribute.TargetAttribute, attributeHolder);
}
else
{
yield return new PowerUpWrapper(
new CombinedElement(attribute.BaseValueElement, levelBonusElmt),
targetAttribute ?? attribute.TargetAttribute,
attributeHolder);
}
}
private IEnumerable<PowerUpWrapper> GetPowerUpsOfItemOptions(Item item, AttributeSystem attributeHolder)
{
var options = item.ItemOptions;
if (options is null)
{
yield break;
}
foreach (var optionLink in options)
{
var option = optionLink.ItemOption;
if (option is null)
{
this._logger.LogWarning("Item {item} (id {itemId}) has ItemOptionLink ({optionLinkId}) without option.", item, item.GetId(), optionLink.GetId());
continue;
}
if (item.ItemSlot == InventoryConstants.RightHandSlot
&& option.OptionType == ItemOptionTypes.Option
&& option.PowerUpDefinition?.TargetAttribute == Stats.WizardryBaseDmg)
{
// For a RH-wielded staff (MG), its wizardry item option doesn't count (but the others do!)
continue;
}
var level = option.LevelType == LevelType.ItemLevel ? item.Level : optionLink.Level;
var optionOfLevel = option.LevelDependentOptions?.FirstOrDefault(l => l.Level == level);
// Dinorant options are an exception.
if (optionOfLevel is null && level > 1 && item.Definition!.Skill?.Number != 49)
{
this._logger.LogWarning("Item {item} (id {itemId}) has IncreasableItemOption ({option}, id {optionId}) with level {level}, but no definition in LevelDependentOptions.", item, item.GetId(), option, option.GetId(), level);
continue;
}
if (optionOfLevel?.RequiredItemLevel > item.Level)
{
// Some options (like harmony) although on the item can be inactive.
continue;
}
var powerUp = optionOfLevel?.PowerUpDefinition ?? option.PowerUpDefinition;
if (powerUp?.Boost is null)
{
// Some options are level dependent. If they are at level 0, they might not have any boost yet.
continue;
}
AggregateType? aggregateType = null;
if (option.OptionType == ItemOptionTypes.Excellent)
{
if (item.ItemSlot == InventoryConstants.PendantSlot
&& option.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmg)
{
// Pendant options are not subject to double wield averaging
aggregateType = AggregateType.AddFinal;
}
else if (option.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmgIncrease
&& item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount))
{
// This needs special treatment, since this option is averaged when double wielding
aggregateType = AggregateType.AddRaw;
}
else
{
// the normal aggregate type should be used
}
}
foreach (var wrapper in PowerUpWrapper.CreateByPowerUpDefinition(powerUp, attributeHolder, aggregateType))
{
yield return wrapper;
}
}
foreach (var powerUpWrapper in this.CreateExcellentAndAncientBasePowerUpWrappers(item, attributeHolder))
{
yield return powerUpWrapper;
}
}
// TODO: Make this more generic and configurable?
private IEnumerable<PowerUpWrapper> CreateExcellentAndAncientBasePowerUpWrappers(Item item, AttributeSystem attributeHolder)
{
var itemIsExcellent = item.IsExcellent();
var itemIsAncient = item.IsAncient();
if (!itemIsAncient && !itemIsExcellent)
{
yield break;
}
var baseDropLevel = item.Definition!.DropLevel;
var ancientDropLevel = item.Definition!.CalculateDropLevel(true, false, 0);
if (InventoryConstants.IsDefenseItemSlot(item.ItemSlot) && !item.IsJewelry())
{
var baseDefense = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseBase)?.BaseValue ?? 0);
var additionalDefense = (baseDefense * 12 / baseDropLevel) + (baseDropLevel / 5) + 4;
yield return new PowerUpWrapper(new SimpleElement(additionalDefense, AggregateType.AddRaw), Stats.DefenseBase, attributeHolder);
if (itemIsAncient)
{
var ancientDefenseBonus = 2 + ((baseDefense + additionalDefense) * 3 / ancientDropLevel) + (ancientDropLevel / 30);
yield return new PowerUpWrapper(new SimpleElement(ancientDefenseBonus, AggregateType.AddRaw), Stats.DefenseBase, attributeHolder);
}
}
if (item.IsShield())
{
var baseDefenseRate = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseRatePvm)?.BaseValue ?? 0);
var additionalRate = (baseDefenseRate * 25 / baseDropLevel) + 5;
yield return new PowerUpWrapper(new SimpleElement(additionalRate, AggregateType.AddRaw), Stats.DefenseRatePvm, attributeHolder);
if (itemIsAncient)
{
var baseDefense = (int)(item.Definition?.BasePowerUpAttributes.FirstOrDefault(a => a.TargetAttribute == Stats.DefenseShield)?.BaseValue ?? 0);
var ancientDefenseBonus = 2 + ((baseDefense + item.Level) * 20 / ancientDropLevel);
yield return new PowerUpWrapper(new SimpleElement(ancientDefenseBonus, AggregateType.AddRaw), Stats.DefenseShield, attributeHolder);
}
}
if (item.IsPhysicalWeapon(out var minPhysDmg))
{
var minDmgAttribute = Stats.MinimumPhysBaseDmgByWeapon;
var maxDmgAttribute = Stats.MaximumPhysBaseDmgByWeapon;
if (item.ItemSlot == InventoryConstants.RightHandSlot
&& item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount || pu.TargetAttribute == Stats.IsOneHandedStaffEquipped))
{
minDmgAttribute = Stats.MinPhysBaseDmgByRightWeapon;
maxDmgAttribute = Stats.MaxPhysBaseDmgByRightWeapon;
}
var additionalDmg = ((int)minPhysDmg * 25 / baseDropLevel) + 5;
yield return new PowerUpWrapper(new SimpleElement(additionalDmg, AggregateType.AddRaw), minDmgAttribute, attributeHolder);
yield return new PowerUpWrapper(new SimpleElement(additionalDmg, AggregateType.AddRaw), maxDmgAttribute, attributeHolder);
if (itemIsAncient)
{
var ancientBonus = 5 + (ancientDropLevel / 40);
yield return new PowerUpWrapper(new SimpleElement(ancientBonus, AggregateType.AddRaw), minDmgAttribute, attributeHolder);
yield return new PowerUpWrapper(new SimpleElement(ancientBonus, AggregateType.AddRaw), maxDmgAttribute, attributeHolder);
}
if (itemIsExcellent
&& item.ItemOptions.Any(io => io.ItemOption?.PowerUpDefinition?.TargetAttribute == Stats.PhysicalBaseDmgIncrease)
&& item.Definition!.BasePowerUpAttributes.Any(pu => pu.TargetAttribute == Stats.DoubleWieldWeaponCount))
{
yield return new PowerUpWrapper(new SimpleElement(-1, AggregateType.AddRaw), Stats.PhysicalBaseDmgIncrease, attributeHolder);
}
}
if (item.IsWizardryWeapon(out var staffRise) && item.ItemSlot == InventoryConstants.LeftHandSlot)
{
var additionalRise = (((int)staffRise * 2 * 25 / baseDropLevel) + 5) / 2;
yield return new PowerUpWrapper(new SimpleElement(additionalRise, AggregateType.AddRaw), Stats.StaffRise, attributeHolder);
if (itemIsAncient)
{
var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2;
yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus, AggregateType.AddRaw), Stats.StaffRise, attributeHolder);
}
}
if (item.IsScepter(out var scepterRise))
{
var additionalRise = (((int)scepterRise * 2 * 25 / baseDropLevel) + 5) / 2;
yield return new PowerUpWrapper(new SimpleElement(additionalRise, AggregateType.AddRaw), Stats.ScepterRise, attributeHolder);
if (itemIsAncient)
{
var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2;
yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus, AggregateType.AddRaw), Stats.ScepterRise, attributeHolder);
}
}
if (item.IsBook(out var curseRise))
{
var additionalRise = (((int)curseRise * 2 * 25 / baseDropLevel) + 5) / 2;
yield return new PowerUpWrapper(new SimpleElement(additionalRise, AggregateType.AddRaw), Stats.BookRise, attributeHolder);
if (itemIsAncient)
{
var ancientRiseBonus = (2 + (ancientDropLevel / 60)) / 2;
yield return new PowerUpWrapper(new SimpleElement(ancientRiseBonus, AggregateType.AddRaw), Stats.BookRise, attributeHolder);
}
}
if (itemIsAncient && item.IsJewelry())
{
foreach (var baseAttribute in item.Definition!.BasePowerUpAttributes)
{
if (Stats.ElementResistanceToDamageBonus.Keys.FirstOrDefault(resistance => resistance == baseAttribute.TargetAttribute) is { } key)
{
yield return new PowerUpWrapper(new SimpleElement(5, AggregateType.AddRaw), Stats.ElementResistanceToDamageBonus[key], attributeHolder);
}
}
}
}
private PowerUpWrapper? GetPetLevel(Item item, AttributeSystem attributeHolder)
{
const byte darkHorseNumber = 4;
if (!item.IsTrainablePet())
{
return null;
}
return new PowerUpWrapper(
new SimpleElement(item.Level, AggregateType.AddRaw),
item.Definition?.Number == darkHorseNumber ? Stats.HorseLevel : Stats.RavenLevel,
attributeHolder);
}
}

View File

@@ -0,0 +1,581 @@
// <copyright file="ItemPriceCalculator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.GameLogic.Attributes;
/// <summary>
/// This calculator calculates the item prices.
/// </summary>
/// <remarks>
/// At the moment it looks all pretty hard coded (like at the original server), so maybe in a future version we could
/// write a more generic calculator, which can be influenced by configuration instead of hard-coded item ids.
/// A nice approach would be to have a "rule" for each item definition, about how to calculate the price.
/// </remarks>
public class ItemPriceCalculator
{
private const short ForceWaveSkillId = 66;
private const short ExplosionSkillId = 223;
private const short RequiemSkillId = 224;
private const short PollutionSkillId = 225;
private const long MaximumPrice = 3_000_000_000;
private const float DestroyedPetPenalty = 2.0f;
private const float DestroyedItemPenalty = 1.4f;
private static readonly List<short> WorthlessSkills = [ForceWaveSkillId, ExplosionSkillId, RequiemSkillId, PollutionSkillId];
private static readonly Dictionary<byte, int> DropLevelIncreaseByLevel = new()
{
{ 5, 4 },
{ 6, 10 },
{ 7, 25 },
{ 8, 45 },
{ 9, 65 },
{ 10, 95 },
{ 11, 135 },
{ 12, 185 },
{ 13, 245 },
{ 14, 305 },
{ 15, 365 },
};
private static readonly IDictionary<int, long> SpecialItemOldValueDictionary = new Dictionary<int, long>
{
{ (int)SpecialItems.Bless, 100_000 },
{ (int)SpecialItems.Soul, 70_000 },
{ (int)SpecialItems.Chaos, 40_000 },
{ (int)SpecialItems.Life, 450_000 },
{ (int)SpecialItems.Creation, 450_000 },
};
private static readonly IDictionary<int, Func<Item, long>> SpecialItemDictionary = new Dictionary<int, Func<Item, long>>
{
{
(int)SpecialItems.Arrow, item =>
{
int gold = 0;
int baseprice = item.Level switch
{
1 => 1200,
2 => 2000,
3 => 2800,
_ => 70,
};
if (item.Durability > 0)
{
gold = baseprice * item.Durability() / item.Definition?.Durability ?? 1;
}
return gold;
}
},
{
(int)SpecialItems.Bolt, item =>
{
int gold = 0;
int baseprice = item.Level switch
{
1 => 1400,
2 => 2200,
3 => 3000,
_ => 100,
};
if (item.Durability > 0)
{
gold = baseprice * item.Durability() / item.Definition?.Durability ?? 1;
}
return gold;
}
},
{ (int)SpecialItems.Bless, _ => 9000000 },
{ (int)SpecialItems.Soul, _ => 6000000 },
{ (int)SpecialItems.Chaos, _ => 810000 },
{ (int)SpecialItems.Life, _ => 45000000 },
{ (int)SpecialItems.Creation, _ => 36000000 },
{ (int)SpecialItems.Guardian, _ => 60000000 },
{ (int)SpecialItems.Gemstone, _ => 18600 },
{ (int)SpecialItems.Harmony, _ => 18600 },
{ (int)SpecialItems.LowerRefineStone, _ => 18600 },
{ (int)SpecialItems.HigherRefineStone, _ => 18600 },
{ (int)SpecialItems.PackedBless, item => (item.Level + 1) * 9000000 * 10 },
{ (int)SpecialItems.PackedSoul, item => (item.Level + 1) * 6000000 * 10 },
{ (int)SpecialItems.PackedChaos, item => (item.Level + 1) * 810000 * 10 },
{ (int)SpecialItems.PackedLife, item => (item.Level + 1) * 45000000 * 10 },
{ (int)SpecialItems.PackedCreation, item => (item.Level + 1) * 36000000 * 10 },
{ (int)SpecialItems.PackedGuardian, item => (item.Level + 1) * 60000000 * 10 },
{ (int)SpecialItems.PackedGemstone, item => (item.Level + 1) * 18600 * 10 },
{ (int)SpecialItems.PackedHarmony, item => (item.Level + 1) * 18600 * 10 },
{ (int)SpecialItems.PackedLowerRefineStone, item => (item.Level + 1) * 18600 * 10 },
{ (int)SpecialItems.PackedHigherRefineStone, item => (item.Level + 1) * 18600 * 10 },
{ (int)SpecialItems.Fruits, _ => 33000000 },
{ (int)SpecialItems.LochFeather, item => item.Level == 1 ? 7500000 : 180000 },
{ (int)SpecialItems.SiegePotion, item => item.Durability() * (item.Level == 0 ? 900000 : 450000) },
{ (int)SpecialItems.OrderGuardianLifeStone, item => item.Level == 1 ? 2400000 : 1000000 },
{ (int)SpecialItems.ContractSummon, item => item.Level == 0 ? 1500000 : item.Level == 1 ? 1200000 : 0 },
{ (int)SpecialItems.SplinterOfArmor, item => item.Durability() * 150 },
{ (int)SpecialItems.BlessOfGuardian, item => item.Durability() * 300 },
{ (int)SpecialItems.ClawOfBeast, item => item.Durability() * 3000 },
{ (int)SpecialItems.FragmentOfHorn, _ => 30000 },
{ (int)SpecialItems.BrokenHorn, _ => 90000 },
{ (int)SpecialItems.HornFenrir, _ => 150000 },
{ (int)SpecialItems.SmallSdPotion, item => item.Durability() * 2000 },
{ (int)SpecialItems.SdPotion, item => item.Durability() * 4000 },
{ (int)SpecialItems.LargeSdPotion, item => item.Durability() * 6000 },
{ (int)SpecialItems.LargeHealPotion, item => item.Durability() * 1500 * (item.Level + 1) },
{ (int)SpecialItems.LargeManaPotion, item => item.Durability() * 1500 * (item.Level + 1) },
{ (int)SpecialItems.SmallComplexPotion, item => item.Durability() * 2500 },
{ (int)SpecialItems.ComplexPotion, item => item.Durability() * 5000 },
{ (int)SpecialItems.LargeComplexPotion, item => item.Durability() * 7500 },
{
(int)SpecialItems.Dinorant, item =>
{
var opts = item.ItemOptions.Where(o => o.ItemOption?.OptionType == ItemOptionTypes.Option).Count();
return 960000 + (300000 * opts);
}
},
{
(int)SpecialItems.DevilEye, item => item.Level == 1 ? 10000 :
item.Level == 2 ? 50000 :
item.Level == 3 ? 100000 :
item.Level == 4 ? 300000 :
item.Level == 5 ? 500000 :
item.Level == 6 ? 800000 :
item.Level == 7 ? 1000000 : 10000
},
{
(int)SpecialItems.DevilKey, item => item.Level == 1 ? 15000 :
item.Level == 2 ? 75000 :
item.Level == 3 ? 150000 :
item.Level == 4 ? 450000 :
item.Level == 5 ? 750000 :
item.Level == 6 ? 1200000 :
item.Level == 7 ? 1500000 : 15000
},
{ (int)SpecialItems.DevilInvitation, item => item.Level is 1 ? 60000 : item.Level == 2 ? 84000 : (item.Level - 1) * 60000 }, // +7 sell price on S6E3 client is 60k (same as +4). Bug?
{ (int)SpecialItems.RemedyOfLove, _ => 900 },
{ (int)SpecialItems.Rena, item => item.Level == 3 ? item.Durability() * 3900 : 9000 },
{ (int)SpecialItems.Ale, _ => 750 },
{ (int)SpecialItems.InvisibleCloak, item => item.Level == 1 ? 150000 : 600000 + ((item.Level - 1) * 60000) },
{
(int)SpecialItems.ScrollOfArchangel, item => item.Level == 1 ? 10000 :
item.Level == 2 ? 50000 :
item.Level == 3 ? 100000 :
item.Level == 4 ? 300000 :
item.Level == 5 ? 500000 :
item.Level == 6 ? 800000 :
item.Level == 7 ? 1000000 :
item.Level == 8 ? 1200000 : 10000
},
{
(int)SpecialItems.BloodBone, item => item.Level == 1 ? 10000 :
item.Level == 2 ? 50000 :
item.Level == 3 ? 100000 :
item.Level == 4 ? 300000 :
item.Level == 5 ? 500000 :
item.Level == 6 ? 800000 :
item.Level == 7 ? 1000000 :
item.Level == 8 ? 1200000 : 10000
},
{ (int)SpecialItems.OldScroll, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 },
{ (int)SpecialItems.IllusionSorcererCovenant, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 },
{ (int)SpecialItems.ScrollOfBlood, item => item.Level == 1 ? 500000 : (item.Level + 1) * 200000 },
{ (int)SpecialItems.FlameOfCondor, _ => 3000000 },
{ (int)SpecialItems.FeatherOfCondor, _ => 3000000 },
{ (int)SpecialItems.ArmorGuardman, _ => 5000 },
{ (int)SpecialItems.WizardsRing, item => item.Level == 0 ? 30000 : 0 },
{ (int)SpecialItems.SpiritPet, item => item.Level == 0 ? 30000000 : item.Level == 1 ? 15000000 : 0 },
{ (int)SpecialItems.LostMap, _ => 600000 },
{ (int)SpecialItems.SymbolKundun, item => 30000 * item.Durability() },
{ (int)SpecialItems.Halloween1, item => 150 * item.Durability() },
{ (int)SpecialItems.Halloween2, item => 150 * item.Durability() },
{ (int)SpecialItems.Halloween3, item => 150 * item.Durability() },
{ (int)SpecialItems.Halloween4, item => 150 * item.Durability() },
{ (int)SpecialItems.Halloween5, item => 150 * item.Durability() },
{ (int)SpecialItems.Halloween6, item => 150 * item.Durability() },
{ (int)SpecialItems.GemOfSecret, item => item.Level == 0 ? 60000 : 0 },
{ (int)SpecialItems.SuspiciousScrapOfPaper, item => 30000 * item.Durability() },
{ (int)SpecialItems.GaionsOrder, item => 30000 * item.Durability() },
{ (int)SpecialItems.FirstSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.SecondSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.ThirdSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.FourthSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.FifthSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.SixthSecromiconFragment, item => 30000 * item.Durability() },
{ (int)SpecialItems.CompleteSecromicon, item => 30000 * item.Durability() },
{ (int)SpecialItems.ChristmasStar, _ => 200000 },
{ (int)SpecialItems.Firecracker, _ => 200000 },
{ (int)SpecialItems.CherryBlossomWine, item => 300 * item.Durability() },
{ (int)SpecialItems.CherryBlossomRiceCake, item => 300 * item.Durability() },
{ (int)SpecialItems.CherryBlossomFlowerPetal, item => 300 * item.Durability() },
{ (int)SpecialItems.GoldenCherryBlossomBranch, item => 300 * item.Durability() },
};
private enum SpecialItems
{
Arrow = 0xF04, // getId(4, 15),
Bolt = 0x704, // getId(4, 7),
Bless = 0xD0E, // getId(14,13),
Soul = 0xE0E, // getId(14,14),
Chaos = 0xF0C, // getId(12,15),
Life = 0x100E, // getId(14,16),
Creation = 0x160E, // getId(14,22),
Guardian = 0x1F0E, // getId(14,31),
Gemstone = 0x290E,
Harmony = 0x2A0E,
LowerRefineStone = 0x2B0E,
HigherRefineStone = 0x2C0E,
PackedBless = 0x1E0C, // getId(12,30),
PackedSoul = 0x1F0C, // getId(12,31),
PackedChaos = 0x8D0C,
PackedLife = 0x880C,
PackedCreation = 0x890C,
PackedGuardian = 0x8A0C,
PackedGemstone = 0x8B0C,
PackedHarmony = 0x8C0C,
PackedLowerRefineStone = 0x8E0C,
PackedHigherRefineStone = 0x8F0C,
Fruits = 0xF0D, // getId(13,15),
LochFeather = 0xE0D, // getId(13,14),
LargeHealPotion = 0x030E,
LargeManaPotion = 0x060E,
SiegePotion = 0x70E, // getId(14,7),
OrderGuardianLifeStone = 0xB0D, // getId(13,11),
ContractSummon = 0x70D, // getId(13,7),
SplinterOfArmor = 0x200D, // getId(13,32),
BlessOfGuardian = 0x210D, // getId(13,33),
ClawOfBeast = 0x220D, // getId(13,34),
FragmentOfHorn = 0x230D, // getId(13,35),
BrokenHorn = 0x240D, // getId(13,36),
HornFenrir = 0x250D, // getId(13,37),
SmallSdPotion = 0x230E, // getId(14,35),
SdPotion = 0x240E, // getId(14, 36),
LargeSdPotion = 0x250E, // getId(14,37),
SmallComplexPotion = 0x260E, // getId(14,38),
ComplexPotion = 0x270E, // getId(14,39),
LargeComplexPotion = 0x280E, // getId(14,40),
Dinorant = 0x30D, // getId(13,3),
DevilEye = 0x110E, // getId(14,17),
DevilKey = 0x120E, // getId(14,18),
DevilInvitation = 0x130E, // getId(14,19),
RemedyOfLove = 0x140E, // getId(14,20),
Rena = 0x150E, // getId(14,21),
Ale = 0x90E, // getId(14,9),
InvisibleCloak = 0x120D, // getId(13,18),
ScrollOfArchangel = 0x100D, // getId(13, 16),
BloodBone = 0x110D, // getId(13,17),
ArmorGuardman = 0x1D0D, // getId(13,29),
WizardsRing = 0x140D, // getId(13,20),
SpiritPet = 0x1F0D, // getId(13,31),
LostMap = 0x1C0E, // getId(14,28),
SymbolKundun = 0x1D0E, // getId(14,29),
Halloween1 = 0x2D0E, // getId(14, 45),
Halloween2 = 0x2E0E, // getId(14, 46),
Halloween3 = 0x2F0E, // getId(14, 47),
Halloween4 = 0x300E, // getId(14, 48),
Halloween5 = 0x310E, // getId(14, 49),
Halloween6 = 0x320E, // getId(14, 50),
GemOfSecret = 0x1A0C, // getId(12,26),
OldScroll = 0x310D,
IllusionSorcererCovenant = 0x320D,
ScrollOfBlood = 0x330D,
FlameOfCondor = 0x340D,
FeatherOfCondor = 0x350D,
SuspiciousScrapOfPaper = 0x650E,
GaionsOrder = 0x660E,
FirstSecromiconFragment = 0x670E,
SecondSecromiconFragment = 0x680E,
ThirdSecromiconFragment = 0x690E,
FourthSecromiconFragment = 0x6A0E,
FifthSecromiconFragment = 0x6B0E,
SixthSecromiconFragment = 0x6C0E,
CompleteSecromicon = 0x6D0E,
ChristmasStar = 0x330E,
Firecracker = 0x3F0E,
CherryBlossomWine = 0x550E,
CherryBlossomRiceCake = 0x560E,
CherryBlossomFlowerPetal = 0x570E,
GoldenCherryBlossomBranch = 0x5A0E,
}
/// <summary>
/// Calculates the selling price of the item for its maximum durability.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The selling price.</returns>
public long CalculateSellingPrice(Item item) => this.CalculateSellingPrice(item, item.GetMaximumDurabilityOfOnePiece());
/// <summary>
/// Calculates the selling price of the item, which the player gets if he is selling an item to a merchant.
/// It's usually a third of the buying price, minus a durability factor.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="durability">The current durability of the <paramref name="item"/>.</param>
/// <returns>The selling price.</returns>
public long CalculateSellingPrice(Item item, byte durability)
{
item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition));
var sellingPrice = CalculateBuyingPrice(item) / 3;
if (item.Definition.Group == 14 && item.Definition.Number <= 8)
{
// Potions + Antidote
return sellingPrice / 10 * 10;
}
if (!item.IsTrainablePet())
{
var maxDurability = item.GetMaximumDurabilityOfOnePiece();
if (maxDurability > 1 && maxDurability > durability)
{
float multiplier = 1.0f - ((float)durability / maxDurability);
long loss = (long)(sellingPrice * 0.6 * multiplier);
sellingPrice -= loss;
}
}
return RoundPrice(sellingPrice);
}
/// <summary>
/// Calculates the repair price of the item, which the player has to pay if he wants to repair the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="npcDiscount">If set to <c>true</c>, the item is repaired through an NPC which gives a discount.</param>
/// <returns>The repair price.</returns>
public long CalculateRepairPrice(Item item, bool npcDiscount)
{
if (item.GetMaximumDurabilityOfOnePiece() == 0)
{
return 0;
}
const long maximumBasePrice = 400_000_000;
var isPet = item.IsTrainablePet();
var basePrice = Math.Min(this.CalculateFinalBuyingPrice(item) / (isPet ? 1 : 3), maximumBasePrice);
basePrice = RoundPrice(basePrice);
float squareRootOfBasePrice = (float)Math.Sqrt(basePrice);
float squareRootOfSquareRoot = (float)Math.Sqrt(squareRootOfBasePrice);
float missingDurability = 1 - ((float)item.Durability() / item.GetMaximumDurabilityOfOnePiece());
float repairPrice = (3.0f * squareRootOfBasePrice * squareRootOfSquareRoot * missingDurability) + 1.0f;
if (item.Durability <= 0)
{
if (isPet)
{
repairPrice *= DestroyedPetPenalty;
}
else
{
repairPrice *= DestroyedItemPenalty;
}
}
if (!npcDiscount)
{
repairPrice *= 2.5f;
}
return RoundPrice((long)repairPrice);
}
/// <summary>
/// Calculates the final buying price of the item, which the player has to pay if he wants to buy the item from a merchant.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The buying price.</returns>
public long CalculateFinalBuyingPrice(Item item) => RoundPrice(CalculateBuyingPrice(item));
/// <summary>
/// Calculates the final "old" buying price of the item.
/// Supposedly in earlier versions jewel reference prices were different, and those were used since for Chaos Weapon and First Wings craftings rate calculations.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>The "old" buying price.</returns>
public long CalculateFinalOldBuyingPrice(Item item)
{
if (SpecialItemOldValueDictionary.TryGetValue(GetId(item.Definition!.Group, item.Definition.Number), out var oldValue))
{
return RoundPrice(oldValue);
}
else
{
return this.CalculateFinalBuyingPrice(item);
}
}
private static int GetId(byte group, int id)
{
return (id << 8) + group;
}
private static long RoundPrice(long price)
{
var result = price;
if (result >= 1000)
{
result = result / 100 * 100;
}
else if (result >= 100)
{
result = result / 10 * 10;
}
else
{
// no rounding for smaller values.
}
return result;
}
private static long CalculateBuyingPrice(Item item)
{
item.ThrowNotInitializedProperty(item.Definition is null, nameof(item.Definition));
var definition = item.Definition!;
if (definition.Value > 0 && (definition.Group == 15 || definition.Group == 12))
{
return definition.Value;
}
if (item.IsTrainablePet())
{
if (item.IsDarkRaven())
{
return item.Level * 1_000_000;
}
else
{
return item.Level * 2_000_000;
}
}
long price = 0;
int dropLevel = definition.DropLevel + (item.Level * 3);
if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent))
{
// increased drop level of excellent item
dropLevel += 25;
}
if (SpecialItemDictionary.TryGetValue(GetId(item.Definition.Group, item.Definition.Number), out var specialItemPriceFunction))
{
price = specialItemPriceFunction(item);
}
else if (definition.Value > 0)
{
price += definition.Value * definition.Value * 10 / 12;
if (item.Definition.Group == 14 && (item.Definition.Number <= 8))
{
// Potions + Antidote
if (item.Level > 0)
{
price *= (long)Math.Pow(2, item.Level);
}
price = price / 10 * 10;
price *= item.Durability();
return price;
}
}
else if ((item.Definition.Group == 12
&& ((item.Definition.Number > 6 && item.Definition.Number < 36)
|| (item.Definition.Number > 43 && item.Definition.Number != 50)))
|| item.Definition.Group == 13
|| item.Definition.Group == 15)
{
// Cape of Lord and Cape of Fighter go here
price = (dropLevel * dropLevel * dropLevel) + 100;
if (item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option) is { } opt
&& opt.ItemOption?.PowerUpDefinition?.TargetAttribute == Stats.HealthRecoveryMultiplier)
{
// Rings, pendants, and capes. Capes with physical damage option have no extra value (possibly a source bug).
price += price * opt.Level;
}
}
else
{
if (DropLevelIncreaseByLevel.TryGetValue(item.Level, out var dropLevelIncrease))
{
dropLevel += dropLevelIncrease;
}
// Wings
if (item.IsWing())
{
// maybe we have to exclude small wings here
price = ((dropLevel + 40) * dropLevel * dropLevel * 11) + 40000000;
}
else
{
price = ((dropLevel + 40) * dropLevel * dropLevel / 8) + 100;
}
var isOneHandedWeapon = item.Definition.Group < 6 && definition.Width < 2;
var isShield = item.Definition.Group == 6;
if (isOneHandedWeapon || isShield)
{
price = price * 80 / 100;
}
if (item.HasSkill && !WorthlessSkills.Contains(definition.Skill?.Number ?? 0))
{
price += (long)(price * 1.5);
}
// add 25% for luck
if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.Luck))
{
price += price * 25 / 100;
}
var opt = item.ItemOptions.FirstOrDefault(o => o.ItemOption?.OptionType == ItemOptionTypes.Option);
var optionLevel = opt?.Level ?? 0;
// Item Options (1 to 4, or 4 to 16)
switch (optionLevel)
{
case 0:
break;
case 1:
price += (long)(price * 0.6);
break;
default:
price += (long)(price * 0.7 * Math.Pow(2, optionLevel - 1));
break;
}
// For each wing option, add 25%
var wingOptionCount = item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Wing);
for (int i = 0; i < wingOptionCount; i++)
{
price += (long)(price * 0.25);
}
// For each excellent option double the value
var excCount = item.ItemOptions.Count(o => o.ItemOption?.OptionType == ItemOptionTypes.Excellent);
for (int i = 0; i < excCount; i++)
{
price += price;
}
}
if (item.ItemOptions.Any(o => o.ItemOption?.OptionType == ItemOptionTypes.GuardianOption))
{
price += price * 16 / 100;
}
if (price > MaximumPrice)
{
price = MaximumPrice;
}
return price;
}
}

View File

@@ -0,0 +1,115 @@
// <copyright file="ItemStorageAdapter.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections;
/// <summary>
/// A wrapper for another <see cref="ItemStorage"/>.
/// Required to split one item storage into more than one storage spaces, e.g. Inventory and Personal Store which use the same ItemStorage.
/// </summary>
/// <seealso cref="ItemStorage"/>
public class ItemStorageAdapter : ItemStorage
{
private readonly CollectionAdapter _adapter;
/// <summary>
/// Initializes a new instance of the <see cref="ItemStorageAdapter"/> class.
/// </summary>
/// <param name="actualStorage">The actual storage.</param>
/// <param name="firstItemSlot">The first item slot.</param>
/// <param name="itemSlotCount">The item slot count.</param>
public ItemStorageAdapter(ItemStorage actualStorage, byte firstItemSlot, byte itemSlotCount)
{
this._adapter = new CollectionAdapter(actualStorage.Items, firstItemSlot, itemSlotCount);
this.ActualStorage = actualStorage;
}
/// <inheritdoc />
public override ICollection<Item> Items => this._adapter;
/// <summary>
/// Gets the actual storage which is wrapped by this instance.
/// </summary>
public ItemStorage ActualStorage { get; }
/// <summary>
/// A collection adapter which just returns items between certain item slots.
/// </summary>
private class CollectionAdapter : ICollection<Item>
{
private readonly ICollection<Item> _actualCollection;
private readonly byte _firstItemSlot;
private readonly byte _itemSlotCount;
public CollectionAdapter(ICollection<Item> actualCollection, byte firstItemSlot, byte itemSlotCount)
{
this._actualCollection = actualCollection;
this._firstItemSlot = firstItemSlot;
this._itemSlotCount = itemSlotCount;
}
/// <inheritdoc />
public int Count => this._actualCollection.Count(i => this.IsSlotOfThisStorage(i.ItemSlot));
/// <inheritdoc />
public bool IsReadOnly => false;
/// <inheritdoc />
public IEnumerator<Item> GetEnumerator()
{
return this._actualCollection.Where(item => this.IsSlotOfThisStorage(item.ItemSlot)).GetEnumerator();
}
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator()
{
return this.GetEnumerator();
}
/// <inheritdoc />
public void Add(Item item)
{
this._actualCollection.Add(item);
}
/// <inheritdoc />
public void Clear()
{
var itemsToRemove = this.ToList();
itemsToRemove.ForEach(item => this._actualCollection.Remove(item));
}
/// <inheritdoc />
public bool Contains(Item item) => item is { } && this.IsSlotOfThisStorage(item.ItemSlot) && this._actualCollection.Contains(item);
/// <inheritdoc />
public void CopyTo(Item[] array, int arrayIndex)
{
var i = arrayIndex;
foreach (var item in this)
{
array[i] = item;
i++;
}
}
/// <inheritdoc />
public bool Remove(Item item)
{
if (this.Contains(item))
{
return this._actualCollection.Remove(item);
}
return false;
}
private bool IsSlotOfThisStorage(byte itemSlot)
{
return itemSlot >= this._firstItemSlot && itemSlot < this._firstItemSlot + this._itemSlotCount;
}
}
}

View File

@@ -0,0 +1,39 @@
// <copyright file="LazyExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Extensions for <see cref="Lazy{T}"/>.
/// </summary>
public static class LazyExtensions
{
/// <summary>
/// Disposes the value of the lazy instance, if it was created, asynchronously.
/// </summary>
/// <typeparam name="T">The type of the <see cref="IAsyncDisposable"/>.</typeparam>
/// <param name="lazy">The lazy instance.</param>
public static async ValueTask DisposeIfCreatedAsync<T>(this Lazy<T> lazy)
where T : IAsyncDisposable
{
if (lazy.IsValueCreated)
{
await lazy.Value.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Disposes the value of the lazy instance, if created.
/// </summary>
/// <typeparam name="T">The <see cref="IDisposable"/>.</typeparam>
/// <param name="lazy">The lazy instance.</param>
public static void DisposeIfCreated<T>(this Lazy<T> lazy)
where T : IDisposable
{
if (lazy.IsValueCreated)
{
lazy.Value.Dispose();
}
}
}

View File

@@ -0,0 +1,72 @@
// <copyright file="LimitedObjectPool.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Threading;
using Microsoft.Extensions.ObjectPool;
/// <summary>
/// An <see cref="IObjectPool{T}"/> which limits the amounts of created
/// <typeparamref name="T"/> to the maximum retained objects.
/// </summary>
/// <typeparam name="T">The type of objects to pool.</typeparam>
internal sealed class LimitedObjectPool<T> : DefaultObjectPool<T>, IObjectPool<T>
where T : class
{
private readonly SemaphoreSlim _semaphore;
/// <summary>
/// Initializes a new instance of the <see cref="LimitedObjectPool{T}"/> class.
/// </summary>
/// <param name="policy">The pooling policy to use.</param>
/// <param name="maximumRetained">The maximum number of objects to create and retain in the pool.</param>
public LimitedObjectPool(IPooledObjectPolicy<T> policy, int maximumRetained)
: base(policy, maximumRetained)
{
this._semaphore = new SemaphoreSlim(maximumRetained, maximumRetained);
}
/// <summary>
/// Initializes a new instance of the <see cref="LimitedObjectPool{T}"/> class.
/// </summary>
/// <param name="policy">The pooling policy to use.</param>
public LimitedObjectPool(IPooledObjectPolicy<T> policy)
: base(policy, MaximumRetainedDefault)
{
this._semaphore = new SemaphoreSlim(MaximumRetainedDefault, MaximumRetainedDefault);
}
/// <summary>
/// Gets the default number of maximum number of pooled objects.
/// </summary>
public static int MaximumRetainedDefault => Environment.ProcessorCount * 2;
/// <inheritdoc />
public async ValueTask<T> GetAsync(CancellationToken cancellationToken = default)
{
await this._semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
return base.Get();
}
/// <inheritdoc />
public override T Get()
{
this._semaphore.Wait();
return base.Get();
}
/// <inheritdoc />
public override void Return(T obj)
{
base.Return(obj);
this._semaphore.Release();
}
/// <inheritdoc />
public void Dispose()
{
this._semaphore.Dispose();
}
}

View File

@@ -0,0 +1,168 @@
// <copyright file="LocateableExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Extensions for mu objects.
/// </summary>
public static class LocateableExtensions
{
/// <summary>
/// Filters out inactive (non-alive) locateables.
/// </summary>
/// <typeparam name="T">Type of elements.</typeparam>
/// <param name="locateables">The locateables.</param>
/// <returns>
/// All active locateables of the given enumeration.
/// </returns>
public static IEnumerable<T> WhereActive<T>(this IEnumerable<T> locateables)
where T : ILocateable
{
return locateables.Where(l => l.IsActive());
}
/// <summary>
/// Filters out invisible locateables.
/// </summary>
/// <typeparam name="T">Type of elements.</typeparam>
/// <param name="locateables">The locateables.</param>
/// <returns>
/// All visible locateables of the given enumeration.
/// </returns>
public static IEnumerable<T> WhereNotInvisible<T>(this IEnumerable<T> locateables)
where T : IAttackable
{
return locateables.Where(l => l.Attributes[Stats.IsInvisible] == 0);
}
/// <summary>
/// Determines whether this instance is active (alive).
/// </summary>
/// <param name="locateable">The locateable.</param>
/// <returns>
/// <c>true</c> if the specified locateable is active; otherwise, <c>false</c>.
/// </returns>
public static bool IsActive(this ILocateable locateable)
{
return locateable is not IAttackable attackable || (attackable.IsAlive && !attackable.IsTeleporting);
}
/// <summary>
/// Gets the distance to another object.
/// </summary>
/// <param name="objectFrom">The object from which the distance is calculated.</param>
/// <param name="objectTo">The object to which the distance is calculated.</param>
/// <returns>The distance between this and another object.</returns>
public static double GetDistanceTo(this ILocateable objectFrom, ILocateable objectTo)
{
return objectFrom.GetDistanceTo(objectTo.Position);
}
/// <summary>
/// Gets the distance to another point.
/// </summary>
/// <param name="objectFrom">The object from which the distance is calculated.</param>
/// <param name="objectToPosition">The point to which the distance is calculated.</param>
/// <returns>The distance between this and another object.</returns>
public static double GetDistanceTo(this ILocateable objectFrom, Point objectToPosition)
{
return objectFrom.Position.EuclideanDistanceTo(objectToPosition);
}
/// <summary>
/// Determines whether the specified coordinates are in the specified range of the object.
/// </summary>
/// <param name="obj">The object.</param>
/// <param name="point">The coordinates.</param>
/// <param name="range">The maximum range.</param>
/// <returns><c>True</c>, if the specified coordinate is in the specified range of the object; Otherwise, <c>false</c>.</returns>
public static bool IsInRange(this ILocateable obj, Point point, int range) => obj.IsInRange(point.X, point.Y, range);
/// <summary>
/// Determines whether the specified coordinates are in the specified range of the object.
/// </summary>
/// <param name="obj">The object.</param>
/// <param name="obj2">The second object.</param>
/// <param name="range">The maximum range.</param>
/// <returns><c>True</c>, if the specified coordinate is in the specified range of the object; Otherwise, <c>false</c>.</returns>
public static bool IsInRange(this ILocateable obj, ILocateable obj2, int range) => obj.IsInRange(obj2.Position, range);
/// <summary>
/// Determines whether the specified coordinate is in the specified range of the object.
/// </summary>
/// <param name="locatable">The object.</param>
/// <param name="x">The x coordinate.</param>
/// <param name="y">The y coordinate.</param>
/// <param name="range">The maximum range.</param>
/// <returns><c>True</c>, if the specified coordinate is in the specified range of the object; Otherwise, <c>false</c>.</returns>
public static bool IsInRange(this ILocateable locatable, int x, int y, int range)
{
int xdiff;
int ydiff;
var point = locatable.Position;
if (x < point.X)
{
xdiff = point.X - x;
}
else if (x > point.X)
{
xdiff = x - point.X;
}
else
{
xdiff = 0;
}
if (xdiff > range)
{
return false;
}
if (y < point.Y)
{
ydiff = point.Y - y;
}
else if (y > point.Y)
{
ydiff = y - point.Y;
}
else
{
ydiff = 0;
}
return ydiff <= range;
}
/// <summary>
/// Gets the direction to another object.
/// </summary>
/// <param name="objectFrom">The object from which the direction is calculated.</param>
/// <param name="objectTo">The object to which the direction is calculated.</param>
/// <returns>The direction between this and another object.</returns>
/// <remarks>
/// The returned values differ a bit, so we first have to analyze which function is correct.
/// </remarks>
public static Direction GetDirectionTo(this ILocateable objectFrom, ILocateable objectTo) => objectFrom.Position.GetDirectionTo(objectTo.Position);
/// <summary>
/// Determines whether the object is at the safezone of his current map.
/// </summary>
/// <param name="obj">The object.</param>
/// <returns>True, if it is on the safezone of his current map; Otherwise, false.</returns>
public static bool IsAtSafezone(this ILocateable obj)
{
var map = obj.CurrentMap;
if (map?.Terrain?.SafezoneMap is null)
{
return true;
}
return map.Terrain.SafezoneMap[obj.Position.X, obj.Position.Y];
}
}

View File

@@ -0,0 +1,38 @@
// <copyright file="LoggerExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
/// <summary>
/// Extensions for <see cref="ILogger"/>s.
/// </summary>
public static class LoggerExtensions
{
/// <summary>
/// Begins a logical operation scope.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="values">The key value pairs which describe the scope.</param>
/// <returns>An <see cref="T:System.IDisposable" /> that ends the logical operation scope on dispose.</returns>
public static IDisposable? BeginScope(this ILogger logger, params (string Key, object Value)[] values)
{
return logger.BeginScope(values.Select(pair => new KeyValuePair<string, object>(pair.Key, pair.Value)));
}
/// <summary>
/// Begins a logical operation scope.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="gameContext">The game context.</param>
/// <returns>An <see cref="T:System.IDisposable" /> that ends the logical operation scope on dispose.</returns>
public static IDisposable? BeginScope(this ILogger logger, IGameContext gameContext)
{
if (gameContext is IGameServerContext gameServerContext)
{
return logger.BeginScope("GameServer {id}", gameServerContext.Id);
}
return null;
}
}

View File

@@ -0,0 +1,67 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<WarningsAsErrors>nullable;CS4014;VSTHRD110;VSTHRD100</WarningsAsErrors>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\..\bin\Debug\</OutputPath>
<DocumentationFile>..\..\bin\Debug\MUnique.OpenMU.GameLogic.xml</DocumentationFile>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\..\bin\Release\</OutputPath>
<DocumentationFile>..\..\bin\Release\MUnique.OpenMU.GameLogic.xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<None Remove="PlugIns\InvasionEvents\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" />
<PackageReference Include="MathParser.org-mXparser" />
<PackageReference Include="Microsoft.Extensions.ObjectPool" />
<PackageReference Include="Nito.AsyncEx" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AttributeSystem\MUnique.OpenMU.AttributeSystem.csproj" />
<ProjectReference Include="..\DataModel\MUnique.OpenMU.DataModel.csproj" />
<ProjectReference Include="..\Interfaces\MUnique.OpenMU.Interfaces.csproj" />
<ProjectReference Include="..\Pathfinding\MUnique.OpenMU.Pathfinding.csproj" />
<ProjectReference Include="..\Persistence\MUnique.OpenMU.Persistence.csproj" />
<ProjectReference Include="..\PlugIns\MUnique.OpenMU.PlugIns.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="PlugIns\InvasionEvents\" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\PlayerMessage.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>PlayerMessage.resx</DependentUpon>
</Compile>
<Compile Update="Properties\PlugInResources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>PlugInResources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\PlayerMessage.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>PlayerMessage.Designer.cs</LastGenOutput>
</EmbeddedResource>
<EmbeddedResource Update="Properties\PlugInResources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>PlugInResources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,173 @@
// <copyright file="MagicEffect.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Diagnostics;
using System.Threading;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A magic effect, usually given by an applied skill or consumed item.
/// </summary>
public class MagicEffect : AsyncDisposable
{
private readonly Timer _finishTimer;
/// <summary>
/// Initializes a new instance of the <see cref="MagicEffect"/> class.
/// </summary>
/// <param name="powerUp">The power up.</param>
/// <param name="definition">The definition.</param>
/// <param name="duration">The duration.</param>
public MagicEffect(IElement powerUp, MagicEffectDefinition definition, TimeSpan duration)
: this(
duration,
definition,
definition.PowerUpDefinitions
.Select(def => new ElementWithTarget(powerUp, def.TargetAttribute ?? throw new InvalidOperationException($"MagicEffectDefinition {definition.GetId()} has no target attribute.")))
.ToArray())
{
}
/// <summary>
/// Initializes a new instance of the <see cref="MagicEffect"/> class.
/// </summary>
/// <param name="duration">The duration.</param>
/// <param name="definition">The definition.</param>
/// <param name="powerUps">The power ups.</param>
public MagicEffect(TimeSpan duration, MagicEffectDefinition definition, params ElementWithTarget[] powerUps)
{
this.PowerUpElements = powerUps;
this.Definition = definition;
this.Duration = duration;
this._finishTimer = new Timer(this.OnTimerTimeout, null, (int)this.Duration.TotalMilliseconds, Timeout.Infinite);
}
/// <summary>
/// Occurs when the effect has been timed out.
/// </summary>
public event AsyncEventHandler<MagicEffect>? EffectTimeOut;
/// <summary>
/// Gets the identifier of the effect.
/// </summary>
public short Id => this.Definition.Number;
/// <summary>
/// Gets or sets the duration of the effect.
/// </summary>
public TimeSpan Duration { get; set; }
/// <summary>
/// Gets the value.
/// </summary>
public float Value
{
get
{
if (!this.PowerUpElements.Any())
{
return 0;
}
return this.PowerUpElements.First().Element.Value;
}
}
/// <summary>
/// Gets or sets the power up elements.
/// </summary>
public IEnumerable<ElementWithTarget> PowerUpElements { get; set; }
/// <summary>
/// Gets the definition.
/// </summary>
public MagicEffectDefinition Definition { get; }
/// <summary>
/// Resets the timer.
/// </summary>
public void ResetTimer()
{
if (this.IsDisposed)
{
throw new ObjectDisposedException(nameof(MagicEffect));
}
this._finishTimer.Change((int)this.Duration.TotalMilliseconds, Timeout.Infinite);
}
/// <inheritdoc/>
protected override async ValueTask DisposeAsyncCore()
{
await this._finishTimer.DisposeAsync().ConfigureAwait(false);
await this.OnEffectTimeOutAsync().ConfigureAwait(false);
this.EffectTimeOut = null;
await base.DisposeAsyncCore().ConfigureAwait(false);
}
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
private async void OnTimerTimeout(object? state)
{
try
{
await this.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
private async ValueTask OnEffectTimeOutAsync()
{
try
{
if (this.EffectTimeOut is { } eventHandler)
{
await eventHandler(this).ConfigureAwait(false);
}
if (!this.IsDisposed && !this.IsDisposing)
{
await this.DisposeAsync().ConfigureAwait(false);
}
}
catch (Exception ex)
{
Debug.Fail(ex.Message, ex.StackTrace);
}
}
/// <summary>
/// Holds the element containing the boost value with its target attribute.
/// </summary>
public class ElementWithTarget
{
/// <summary>
/// Initializes a new instance of the <see cref="ElementWithTarget"/> class.
/// </summary>
/// <param name="element">The element.</param>
/// <param name="target">The target attribute.</param>
public ElementWithTarget(IElement element, AttributeDefinition target)
{
this.Element = element;
this.Target = target;
}
/// <summary>
/// Gets the element containing the boost value.
/// </summary>
public IElement Element { get; }
/// <summary>
/// Gets the target attribute.
/// </summary>
public AttributeDefinition Target { get; }
}
}

View File

@@ -0,0 +1,206 @@
// <copyright file="MagicEffectsList.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.GameLogic.Views.World;
using Nito.AsyncEx;
/// <summary>
/// The list of magic effects of a player instance. Automatically applies the power-ups of the effects to the player.
/// </summary>
public class MagicEffectsList : AsyncDisposable
{
private const byte InvisibleEffectStartIndex = 200;
private readonly BitArray _contains = new(0x100);
private readonly IAttackable _owner;
private readonly AsyncLock _addLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="MagicEffectsList"/> class.
/// </summary>
/// <param name="owner">The attackable which owns this list.</param>
public MagicEffectsList(IAttackable owner)
{
this._owner = owner;
this.ActiveEffects = new SortedList<short, MagicEffect>(6);
}
/// <summary>
/// Gets the active effects.
/// </summary>
public IDictionary<short, MagicEffect> ActiveEffects { get; }
/// <summary>
/// Gets the active visible effect ids.
/// </summary>
public IList<MagicEffect> VisibleEffects => this.ActiveEffects.Values.Where(me => me.Definition.InformObservers).ToList();
/// <summary>
/// Adds the effect and applies the power ups.
/// </summary>
/// <param name="effect">The effect.</param>
public async ValueTask AddEffectAsync(MagicEffect effect)
{
bool added = false;
using (await this._addLock.LockAsync())
{
if (this._contains[effect.Id])
{
this.UpdateEffect(effect);
}
else
{
added = true;
this.ActiveEffects.Add(effect.Id, effect);
this._contains[effect.Id] = true;
foreach (var powerUp in effect.PowerUpElements)
{
this._owner.Attributes.AddElement(powerUp.Element, powerUp.Target);
}
}
}
if (added)
{
effect.EffectTimeOut += this.OnEffectTimeOutAsync;
if (effect.Id < InvisibleEffectStartIndex && this._owner is IWorldObserver observer)
{
await observer.InvokeViewPlugInAsync<IActivateMagicEffectPlugIn>(p => p.ActivateMagicEffectAsync(effect, this._owner)).ConfigureAwait(false);
}
if (effect.Id < InvisibleEffectStartIndex && effect.Definition.InformObservers && this._owner is IObservable observable)
{
await observable.ForEachWorldObserverAsync<IActivateMagicEffectPlugIn>(p => p.ActivateMagicEffectAsync(effect, this._owner), false).ConfigureAwait(false);
}
}
else
{
effect.Dispose();
}
}
/// <summary>
/// Clears all active effects.
/// </summary>
public async ValueTask ClearAllEffectsAsync()
{
while (this.ActiveEffects.Any())
{
await this.ActiveEffects.Values.First().DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Clear the effects that produce a specific stat.
/// </summary>
/// <param name="stat">The stat produced by effect.</param>
public async ValueTask ClearAllEffectsProducingSpecificStatAsync(AttributeDefinition stat)
{
var effects = this.ActiveEffects.Values.ToArray();
foreach (var effect in effects)
{
if (effect.PowerUpElements.Any(p => p.Target == stat))
{
await effect.DisposeAsync().ConfigureAwait(false);
}
}
}
/// <summary>
/// Clears the effects after death of the player.
/// </summary>
public async ValueTask ClearEffectsAfterDeathAsync()
{
var effectsToRemove = this.ActiveEffects.Values.Where(effect => effect.Definition.StopByDeath).ToList();
foreach (var effect in effectsToRemove)
{
await effect.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Tries to get the currently active effect of the specified <see cref="MagicEffectDefinition.SubType"/>.
/// </summary>
/// <param name="subType">The <see cref="MagicEffectDefinition.SubType"/>.</param>
/// <returns>The effect, if found.</returns>
public async ValueTask<MagicEffect?> TryGetActiveEffectOfSubTypeAsync(byte subType)
{
using var l = await this._addLock.LockAsync();
return this.ActiveEffects.Values.FirstOrDefault(e => e.Definition.SubType == subType);
}
/// <inheritdoc />
protected override async ValueTask DisposeAsyncCore()
{
await this.ClearAllEffectsAsync().ConfigureAwait(false);
await base.DisposeAsyncCore().ConfigureAwait(false);
}
private async ValueTask OnEffectTimeOutAsync(MagicEffect effect)
{
using (await this._addLock.LockAsync())
{
this.ActiveEffects.Remove(effect.Id);
this._contains[effect.Id] = false;
}
foreach (var powerUp in effect.PowerUpElements)
{
this._owner.Attributes.RemoveElement(powerUp.Element, powerUp.Target);
}
if (effect.Id >= InvisibleEffectStartIndex)
{
return;
}
(this._owner as IWorldObserver)?.InvokeViewPlugInAsync<IDeactivateMagicEffectPlugIn>(p => p.DeactivateMagicEffectAsync(effect, this._owner));
if (effect.Definition.InformObservers && this._owner.IsAlive)
{
(this._owner as IObservable)?.ForEachWorldObserverAsync<IDeactivateMagicEffectPlugIn>(p => p.DeactivateMagicEffectAsync(effect, this._owner), false);
}
}
/// <summary>
/// Updates the effect.
/// </summary>
/// <param name="effect">The effect.</param>
private void UpdateEffect(MagicEffect effect)
{
MagicEffect magicEffect = this.ActiveEffects[effect.Id];
if (magicEffect.Value > effect.Value)
{
// no de-buffing allowed
return;
}
//// GMO behaviour would be: RemoveEffect(magicEffect.Id); AddEffectAsync(effect);
//// I change the existing Timer and Buff Value, without removing the effect itself.
//// This doesn't only save traffic, it also looks better in game.
magicEffect.Duration = effect.Duration;
magicEffect.ResetTimer();
if (magicEffect.PowerUpElements.Select(e => e.Element)
.SequenceEqual(effect.PowerUpElements.Select(e => e.Element)))
{
// if the effect power ups are the same, we can leave it like that
return;
}
foreach (var powerUp in magicEffect.PowerUpElements)
{
this._owner.Attributes.RemoveElement(powerUp.Element, powerUp.Target);
}
magicEffect.PowerUpElements = effect.PowerUpElements;
foreach (var powerUp in magicEffect.PowerUpElements)
{
this._owner.Attributes.AddElement(powerUp.Element, powerUp.Target);
}
}
}

View File

@@ -0,0 +1,391 @@
// <copyright file="MapInitializer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views.NPC;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// A basic map initializer.
/// </summary>
/// <seealso cref="MUnique.OpenMU.GameLogic.IMapInitializer" />
public class MapInitializer : IMapInitializer
{
private readonly IDropGenerator _dropGenerator;
private readonly IConfigurationChangeMediator? _configurationChangeMediator;
private readonly GameConfiguration _configuration;
private readonly ILogger<MapInitializer> _logger;
private readonly ConcurrentDictionary<MonsterSpawnArea, int> _spawnedMonsters = new();
/// <summary>
/// Initializes a new instance of the <see cref="MapInitializer" /> class.
/// </summary>
/// <param name="configuration">The configuration.</param>
/// <param name="logger">The logger.</param>
/// <param name="dropGenerator">The drop generator.</param>
/// <param name="configurationChangeMediator">The configuration change mediator.</param>
public MapInitializer(GameConfiguration configuration, ILogger<MapInitializer> logger, IDropGenerator dropGenerator, IConfigurationChangeMediator? configurationChangeMediator)
{
this._dropGenerator = dropGenerator;
this._configurationChangeMediator = configurationChangeMediator;
this._configuration = configuration;
this._logger = logger;
this.ChunkSize = 8;
}
/// <summary>
/// Gets or sets the plug in manager.
/// </summary>
public PlugInManager? PlugInManager { get; set; }
/// <summary>
/// Gets or sets the path finder pool.
/// </summary>
/// <value>
/// The path finder pool.
/// </value>
public IObjectPool<PathFinder>? PathFinderPool { get; set; }
/// <summary>
/// Gets or sets the size of the chunk of created <see cref="GameMap"/>s.
/// </summary>
/// <value>
/// The size of the chunk of created <see cref="GameMap"/>s.
/// </value>
protected byte ChunkSize { get; set; }
/// <inheritdoc/>
public GameMap? CreateGameMap(ushort mapNumber)
{
var definition = this.GetMapDefinition(mapNumber);
if (definition != null)
{
return this.InternalCreateGameMap(definition);
}
return null;
}
/// <summary>
/// Creates a new game map instance with the specified definition.
/// </summary>
/// <param name="mapDefinition">The map definition.</param>
/// <returns>The new game map instance.</returns>
public GameMap CreateGameMap(GameMapDefinition mapDefinition)
{
return this.InternalCreateGameMap(mapDefinition);
}
/// <inheritdoc />
public async ValueTask InitializeStateAsync(GameMap createdMap)
{
_ = this.PlugInManager ?? throw new InvalidOperationException("PlugInManager must be set first");
_ = this.PathFinderPool ?? throw new InvalidOperationException("PathFinderPool must be set first");
this._logger.LogDebug("Start creating monster instances for map {createdMap}", createdMap.Definition.Name);
var automaticSpawns = createdMap.Definition.MonsterSpawns
.Where(m => m.MonsterDefinition is not null)
.Where(m => m.SpawnTrigger is SpawnTrigger.Automatic);
foreach (var spawnArea in automaticSpawns)
{
for (int i = 0; i < spawnArea.Quantity; i++)
{
await this.InitializeSpawnAsync(i, createdMap, spawnArea).ConfigureAwait(false);
}
this._spawnedMonsters.AddOrUpdate(spawnArea, spawnArea.Quantity, (_, _) => spawnArea.Quantity);
}
this._configurationChangeMediator?.RegisterForNew<MonsterSpawnArea, GameMap>(createdMap, async (spawnArea, map) =>
{
if (!Equals(spawnArea.GameMap, map.Definition))
{
return;
}
for (int i = 0; i < spawnArea.Quantity; i++)
{
await this.InitializeSpawnAsync(i, map, spawnArea).ConfigureAwait(false);
}
this._spawnedMonsters.AddOrUpdate(spawnArea, spawnArea.Quantity, (_, _) => spawnArea.Quantity);
});
this._logger.LogDebug("Finished creating monster instances for map {createdMap}", createdMap.Definition.Name);
}
/// <summary>
/// Initializes the event NPCs of the previously created game map.
/// </summary>
/// <param name="createdMap">The created map.</param>
/// <param name="eventStateProvider">The event state provider.</param>
public async ValueTask InitializeNpcsOnEventStartAsync(GameMap createdMap, IEventStateProvider eventStateProvider)
{
_ = this.PlugInManager ?? throw new InvalidOperationException("PlugInManager must be set first");
_ = this.PathFinderPool ?? throw new InvalidOperationException("PathFinderPool must be set first");
this._logger.LogDebug("Start creating event monster instances for map {createdMap}", createdMap.Definition.Name);
var eventSpawns = createdMap.Definition.MonsterSpawns
.Where(m => m.MonsterDefinition is not null)
.Where(m => m.SpawnTrigger is SpawnTrigger.OnceAtEventStart or SpawnTrigger.AutomaticDuringEvent);
foreach (var spawnArea in eventSpawns)
{
for (int i = 0; i < spawnArea.Quantity; i++)
{
await this.InitializeSpawnAsync(i, createdMap, spawnArea, eventStateProvider).ConfigureAwait(false);
}
}
this._logger.LogDebug("Finished creating event monster instances for map {createdMap}", createdMap.Definition.Name);
}
/// <inheritdoc />
public async ValueTask InitializeNpcsOnWaveStartAsync(GameMap createdMap, IEventStateProvider eventStateProvider, byte waveNumber)
{
_ = this.PlugInManager ?? throw new InvalidOperationException("PlugInManager must be set first");
_ = this.PathFinderPool ?? throw new InvalidOperationException("PathFinderPool must be set first");
this._logger.LogDebug("Start creating event monster instances for map {createdMap}", createdMap.Definition.Name);
var waveSpawns = createdMap.Definition.MonsterSpawns
.Where(m => m.MonsterDefinition is not null)
.Where(m => m.SpawnTrigger is SpawnTrigger.AutomaticDuringWave or SpawnTrigger.OnceAtWaveStart)
.Where(m => m.WaveNumber == waveNumber);
foreach (var spawnArea in waveSpawns)
{
for (int i = 0; i < spawnArea.Quantity; i++)
{
await this.InitializeSpawnAsync(i, createdMap, spawnArea, eventStateProvider).ConfigureAwait(false);
}
}
this._logger.LogDebug("Finished creating event monster instances for map {createdMap}", createdMap.Definition.Name);
}
/// <inheritdoc />
public async ValueTask<NonPlayerCharacter?> InitializeSpawnAsync(
int spawnIndex,
GameMap createdMap,
MonsterSpawnArea spawnArea,
IEventStateProvider? eventStateProvider = null,
IDropGenerator? dropGenerator = null)
{
_ = this.PlugInManager ?? throw new InvalidOperationException("PlugInManager must be set first");
_ = this.PathFinderPool ?? throw new InvalidOperationException("PathFinderPool must be set first");
var monsterDef = spawnArea.MonsterDefinition!;
NonPlayerCharacter npc;
var intelligence = this.TryCreateConfiguredNpcIntelligence(monsterDef, createdMap);
if (monsterDef.ObjectKind == NpcObjectKind.Monster)
{
this._logger.LogDebug("Creating monster {spawn}", spawnArea);
npc = new Monster(spawnArea, monsterDef, createdMap, dropGenerator ?? this._dropGenerator, intelligence ?? new BasicMonsterIntelligence(), this.PlugInManager, this.PathFinderPool, eventStateProvider);
}
else if (monsterDef.ObjectKind == NpcObjectKind.Guard)
{
this._logger.LogDebug("Creating guard {spawn}", spawnArea);
npc = new Monster(spawnArea, monsterDef, createdMap, NullDropGenerator.Instance, intelligence ?? new GuardIntelligence(), this.PlugInManager, this.PathFinderPool, eventStateProvider);
}
else if (monsterDef.ObjectKind == NpcObjectKind.Trap)
{
this._logger.LogDebug("Creating trap {spawn}", spawnArea);
npc = new Trap(spawnArea, monsterDef, createdMap, intelligence ?? new RandomAttackInRangeTrapIntelligence(createdMap));
}
else if (monsterDef.ObjectKind == NpcObjectKind.SoccerBall)
{
this._logger.LogDebug("Creating soccer ball {spawn}", spawnArea);
npc = new SoccerBall(spawnArea, monsterDef, createdMap);
}
else if (monsterDef.ObjectKind == NpcObjectKind.Destructible)
{
this._logger.LogDebug("Creating destructible {spawn}", spawnArea);
npc = new Destructible(spawnArea, monsterDef, createdMap, eventStateProvider, dropGenerator ?? this._dropGenerator, this.PlugInManager!);
}
else if (monsterDef.MerchantStore is not null)
{
this._logger.LogDebug("Creating merchant npc {spawn}", spawnArea);
npc = new MerchantNpc(spawnArea, monsterDef, createdMap);
}
else
{
this._logger.LogDebug("Creating npc {spawn}", spawnArea);
npc = new NonPlayerCharacter(spawnArea, monsterDef, createdMap);
}
try
{
npc.SpawnIndex = spawnIndex;
npc.Initialize();
await createdMap.AddAsync(npc).ConfigureAwait(false);
npc.OnSpawn();
if (spawnArea.SpawnTrigger is SpawnTrigger.Automatic or SpawnTrigger.Wandering)
{
this.RegisterForConfigChanges(createdMap, spawnArea, npc);
}
return npc;
}
catch (Exception ex)
{
this._logger.LogError(ex, $"Object {spawnArea} couldn't be initialized.", spawnArea);
await npc.DisposeAsync().ConfigureAwait(false);
}
return null;
}
/// <summary>
/// Gets the map definition by searching for it at the <see cref="GameConfiguration"/>.
/// </summary>
/// <param name="mapNumber">The map number.</param>
/// <returns>The game map definition.</returns>
protected virtual GameMapDefinition? GetMapDefinition(ushort mapNumber)
{
return this._configuration.Maps.FirstOrDefault(m => m.Number == mapNumber);
}
/// <summary>
/// Creates the game map instance with the specified definition.
/// </summary>
/// <param name="definition">The definition.</param>
/// <returns>
/// The created game map instance.
/// </returns>
protected virtual GameMap InternalCreateGameMap(GameMapDefinition definition)
{
this._logger.LogDebug("Creating GameMap {0}", definition);
return definition.BattleZone?.Type == BattleType.Soccer
? new SoccerGameMap(definition, this._configuration.ItemDropDuration, this.ChunkSize)
: new GameMap(definition, this._configuration.ItemDropDuration, this.ChunkSize);
}
private void RegisterForConfigChanges(GameMap createdMap, MonsterSpawnArea spawnArea, NonPlayerCharacter spawnedObject)
{
// Apply changes of the monster definition (e.g. its attributes) instantly to the
// already spawned instance, without having to re-spawn it. The registration is owned
// by the NPC, so it gets disposed whenever the NPC is disposed.
if (spawnedObject is AttackableNpcBase attackableNpc
&& this._configurationChangeMediator?.RegisterObject(
spawnedObject.Definition,
attackableNpc,
(_, _, o) =>
{
o.ReloadAttributes();
return ValueTask.CompletedTask;
}) is { } definitionRegistration)
{
attackableNpc.RegisterDisposable(definitionRegistration);
}
this._configurationChangeMediator?.RegisterObject(
spawnArea,
spawnedObject,
async (unregisterAction, area, o) =>
{
if (area.Quantity < o.SpawnIndex + 1)
{
await o.DisposeAsync().ConfigureAwait(false);
unregisterAction();
this._spawnedMonsters.AddOrUpdate(spawnArea, spawnArea.Quantity, (_, _) => spawnArea.Quantity);
return;
}
// If the monster definition changed, we need to spawn a completely new NPC
// because MonsterAttributeHolder caches stats from the definition at construction time.
if (area.MonsterDefinition != o.Definition)
{
await o.DisposeAsync().ConfigureAwait(false);
unregisterAction();
var newNpc = await this.InitializeSpawnAsync(o.SpawnIndex, createdMap, area).ConfigureAwait(false);
if (newNpc is not null)
{
this._spawnedMonsters.AddOrUpdate(spawnArea, spawnArea.Quantity, (_, _) => spawnArea.Quantity);
this.RegisterForConfigChanges(createdMap, area, newNpc);
}
return;
}
await createdMap.RemoveAsync(o).ConfigureAwait(false);
o.Initialize();
await createdMap.AddAsync(o).ConfigureAwait(false);
o.OnSpawn();
if (this._spawnedMonsters.TryGetValue(area, out var previousSpawnCount))
{
for (int i = previousSpawnCount; i < area.Quantity; i++)
{
await this.InitializeSpawnAsync(i, createdMap, area).ConfigureAwait(false);
}
this._spawnedMonsters.AddOrUpdate(spawnArea, spawnArea.Quantity, (_, _) => spawnArea.Quantity);
}
},
async (_, o) =>
{
await o.DisposeAsync().ConfigureAwait(false);
this._spawnedMonsters.TryRemove(spawnArea, out var _);
});
if (spawnedObject.Definition.MerchantStore is { } merchantStore)
{
this._configurationChangeMediator?.RegisterObject(merchantStore, spawnedObject, async (_, itemStorage, o) =>
{
await o.ForEachObservingAsync<Player>(
async player =>
{
if (player.OpenedNpc == o)
{
await player.InvokeViewPlugInAsync<IShowMerchantStoreItemListPlugIn>(
plugin => plugin.ShowMerchantStoreItemListAsync(itemStorage.Items, StoreKind.Normal))
.ConfigureAwait(false);
}
},
false).ConfigureAwait(false);
});
}
}
private INpcIntelligence? TryCreateConfiguredNpcIntelligence(MonsterDefinition monsterDefinition, GameMap createdMap)
{
if (string.IsNullOrWhiteSpace(monsterDefinition.IntelligenceTypeName))
{
return null;
}
try
{
var type = Type.GetType(monsterDefinition.IntelligenceTypeName);
if (type is null)
{
this._logger.LogError($"Could not find type {monsterDefinition.IntelligenceTypeName}");
return null;
}
var constructorNeedsMap = type.GetConstructors().Any(c => c.GetParameters().Any(p => p.ParameterType == typeof(GameMap)));
if (constructorNeedsMap)
{
return Activator.CreateInstance(type, createdMap) as INpcIntelligence;
}
else
{
return Activator.CreateInstance(type) as INpcIntelligence;
}
}
catch (Exception ex)
{
this._logger.LogError(ex, $"Could not create npc intelligence for monster {monsterDefinition.Designation}, type name {monsterDefinition.IntelligenceTypeName}");
}
return null;
}
}

View File

@@ -0,0 +1,118 @@
// <copyright file="MasterSkillExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using System.Collections.Concurrent;
using org.mariuszgromada.math.mxparser;
/// <summary>
/// Extension methods regarding master skills.
/// </summary>
public static class MasterSkillExtensions
{
private static readonly ConcurrentDictionary<string, float[]> ValueResultCache = new();
/// <summary>
/// Calculates the effective value of the specified skill, depending on its formula and level.
/// </summary>
/// <param name="skillEntry">The skill entry of the master skill.</param>
/// <returns>The value of the specified skill, depending on its formula and level.</returns>
public static float CalculateValue(this SkillEntry skillEntry) => skillEntry.Skill?.MasterDefinition?.CalculateValue(skillEntry.Level) ?? 0;
/// <summary>
/// Calculates the display value of the specified skill, depending on its formula and level.
/// </summary>
/// <param name="skillEntry">The skill entry of the master skill.</param>
/// <returns>The value of the specified skill, depending on its formula and level.</returns>
public static float CalculateDisplayValue(this SkillEntry skillEntry) => skillEntry.Skill?.MasterDefinition?.CalculateDisplayValue(skillEntry.Level) ?? 0;
/// <summary>
/// Calculates the next display value of the specified skill, depending on its formula and level.
/// </summary>
/// <param name="skillEntry">The skill entry of the master skill.</param>
/// <returns>The value of the specified skill, depending on its formula and level.</returns>
public static float CalculateNextDisplayValue(this SkillEntry skillEntry)
{
var level = Math.Min(skillEntry.Level + 1, skillEntry.Skill?.MasterDefinition?.MaximumLevel ?? 0);
return skillEntry.Skill?.MasterDefinition.CalculateDisplayValue(level) ?? 0;
}
/// <summary>
/// Gets the base <see cref="Skill"/> of a <see cref="SkillEntry"/>.
/// </summary>
/// <param name="skillEntry">The <see cref="SkillEntry"/>.</param>
/// <returns>The base <see cref="Skill"/>.</returns>
public static Skill GetBaseSkill(this SkillEntry skillEntry)
{
return skillEntry.Skill!.GetBaseSkill();
}
/// <summary>
/// Gets the base <see cref="Skill"/> of a <see cref="Skill"/>.
/// </summary>
/// <param name="skill">The <see cref="Skill"/>.</param>
/// <returns>The base <see cref="Skill"/>.</returns>
public static Skill GetBaseSkill(this Skill skill)
{
while (skill.MasterDefinition?.ReplacedSkill is { } replacedSkill)
{
skill = replacedSkill;
}
return skill;
}
/// <summary>
/// Gets the base <see cref="Skill"/>s of a <see cref="Skill"/>.
/// </summary>
/// <param name="skill">The <see cref="Skill"/>.</param>
/// <param name="onlyMasterSkills">If set to <c>true</c>, only master skills are returned.</param>
/// <returns>The base <see cref="Skill"/>.</returns>
public static IEnumerable<Skill> GetBaseSkills(this Skill skill, bool onlyMasterSkills = false)
{
while (skill.MasterDefinition?.ReplacedSkill is { } replacedSkill)
{
if (onlyMasterSkills && replacedSkill.MasterDefinition is null)
{
yield break;
}
else
{
skill = replacedSkill;
yield return skill;
}
}
}
private static float CalculateValue(this MasterSkillDefinition? skillDefinition, int level) => skillDefinition?.ValueFormula.GetValue(level, skillDefinition.MaximumLevel) ?? 0;
private static float CalculateDisplayValue(this MasterSkillDefinition? skillDefinition, int level) => skillDefinition?.DisplayValueFormula.GetValue(level, skillDefinition.MaximumLevel) ?? 0;
private static float GetValue(this string formula, int level, int maximumLevel)
{
if (level <= 0 || level > maximumLevel)
{
return 0f;
}
if (!ValueResultCache.TryGetValue(formula, out var results))
{
results = new float[maximumLevel];
var argument = new Argument("level", 0);
var expression = new Expression(formula);
expression.addArguments(argument);
for (int i = 0; i < maximumLevel; i++)
{
argument.setArgumentValue(i + 1);
results[i] = (float)expression.calculate();
}
ValueResultCache.TryAdd(formula, results);
}
return results[level - 1];
}
}

27
src/GameLogic/Metrics.cs Normal file
View File

@@ -0,0 +1,27 @@
// <copyright file="Metrics.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic;
using MUnique.OpenMU.GameLogic.PlayerActions.Trade;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// Provides information about the available metrics of this project.
/// </summary>
public static class Metrics
{
/// <summary>
/// Gets all available meters of this project.
/// </summary>
public static IEnumerable<string> Meters
{
get
{
yield return GameContext.MeterName;
yield return BaseTradeAction.MeterName;
yield return PathFinder.MeterName;
}
}
}

View File

@@ -0,0 +1,375 @@
// <copyright file="BloodCastleContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System.Collections.Concurrent;
using System.Threading;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.GameLogic.Views.Character;
using MUnique.OpenMU.GameLogic.Views.Inventory;
using Nito.Disposables.Internals;
/// <summary>
/// The context of a blood castle game.
/// </summary>
/// <remarks>
/// A blood castle event works like that:
/// First, a player or his party (and probably another party), maximum 10 players, enter the blood castle.
/// The game has several states:
/// * After the event starts, first a certain amount of monsters have to be killed, so that a bridge appears on the way to the castle gate.
/// * The castle gate has to be destroyed
/// * Some stronger monsters wait after the castle gate. A certain amount of "Spirit Sorcerer" have to be killed.
/// * The statue appears, which needs to be killed.
/// * The statue drops an archangel weapon as quest item. This item has to be brought back to the archangel NPC.
/// The game has a time limit of usually 15 or 20 minutes.
/// After the time is up, or the quest item has been brought back, the players get some rewards:
///
/// Experience:
/// 1. For each remaining second, a bonus experience is given as experience.
/// 2. The player or party which destroyed the gate, gets extra experience. Dead party members get the half of the exp as bonus.
/// 3. For killing the statue, the player/party gets a bonus experience.
/// 4. For finishing the quest, the player/party gets a bonus experience.
/// 5. To all previous exp rewards, bonuses from seals, maps etc. are applied.
/// Money:
/// According to the reward table - fixed values per blood castle level, depending if the player was in the winning party, or not.
/// The winner/winners party get roughly the double money value.
/// Score:
/// a) If the event was won by any participant, depending on the individual success state of a player,
/// it will get a different score rewarded. A player is categorized into these 5 states:
/// - unfinished event
/// - died during event
/// - winner
/// - member of winners party
/// - member of winners party, but died during event.
/// b) If the event wasn't won by any participant, players are getting a score penalty of 300.
/// </remarks>
public sealed class BloodCastleContext : MiniGameContext
{
private const short CastleGateNumber = 131;
private const short StatueOfSaintNumber = 132;
/// <summary>
/// Dialog category for the NPC interactions.
/// </summary>
private const byte DialogCategoryMain = 1;
private readonly ConcurrentDictionary<string, PlayerGameState> _gameStates = new();
private IReadOnlyCollection<(string Name, int Score, int BonusExp, int BonusMoney)>? _highScoreTable;
private TimeSpan _remainingTime;
private bool _gateDestroyed;
private Player? _winner;
private Player? _questItemOwner;
private Item? _questItem;
/// <summary>
/// Initializes a new instance of the <see cref="BloodCastleContext"/> class.
/// </summary>
/// <param name="key">The key of this context.</param>
/// <param name="definition">The definition of the mini game.</param>
/// <param name="gameContext">The game context, to which this game belongs.</param>
/// <param name="mapInitializer">The map initializer, which is used when the event starts.</param>
public BloodCastleContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
}
/// <summary>
/// Dialog numbers for different interactions with the NPC.
/// </summary>
private enum DialogNumber : byte
{
EventWinner = 0x17,
EventNotRunning = 0x18,
EventQuestItemMissing = 0x18,
EventFinished = 0x2E,
}
/// <inheritdoc />
protected override Player? Winner => this._winner;
/// <inheritdoc />
protected override TimeSpan RemainingTime => this._remainingTime;
/// <summary>
/// Player interact with Archangel.
/// </summary>
/// <param name="player">The player who talks to Archangel.</param>
public async ValueTask TalkToNpcArchangelAsync(Player player)
{
if (this._winner is not null)
{
await player.InvokeViewPlugInAsync<IShowDialogPlugIn>(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventFinished)).ConfigureAwait(false);
return;
}
if (!this.IsEventRunning)
{
await player.InvokeViewPlugInAsync<IShowDialogPlugIn>(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventNotRunning)).ConfigureAwait(false);
return;
}
if (!await this.TryRemoveQuestItemFromPlayerAsync(player).ConfigureAwait(false))
{
await player.InvokeViewPlugInAsync<IShowDialogPlugIn>(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventQuestItemMissing)).ConfigureAwait(false);
return;
}
this._winner = player;
await player.InvokeViewPlugInAsync<IShowDialogPlugIn>(p => p.ShowDialogAsync(DialogCategoryMain, (byte)DialogNumber.EventWinner)).ConfigureAwait(false);
this.FinishEvent();
}
/// <inheritdoc/>
protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args)
{
if (args.Object is Player player)
{
if (this.IsEventRunning)
{
// Drop it, so that the remaining players can pick it up.
await this.TryDropQuestItemFromPlayerAsync(player).ConfigureAwait(false);
}
else
{
await this.TryRemoveQuestItemFromPlayerAsync(player).ConfigureAwait(false);
}
await this.UpdateStateAsync(BloodCastleStatus.Ended, player).ConfigureAwait(false);
}
await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false);
}
/// <inheritdoc />
[System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100:Avoid async void methods", Justification = "Catching all Exceptions.")]
protected override async void OnDestructibleDied(object? sender, DeathInformation e)
{
try
{
base.OnDestructibleDied(sender, e);
var destructible = sender as Destructible;
if (destructible is null)
{
return;
}
if (destructible.Definition.Number == StatueOfSaintNumber)
{
await this.ShowGoldenMessageAsync(nameof(PlayerMessage.BloodCastleCrystalStatusDestroyed), e.KillerName).ConfigureAwait(false);
}
else if (destructible.Definition.Number == CastleGateNumber)
{
this._gateDestroyed = true;
}
else
{
// we don't have others, so nothing to do
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Unexpected error in OnDestructibleDied.");
}
}
/// <inheritdoc/>
protected override async ValueTask OnPlayerPickedUpItemAsync((Player Picker, ILocateable DroppedItem) args)
{
await base.OnPlayerPickedUpItemAsync(args).ConfigureAwait(false);
if (args.DroppedItem is DroppedItem { Item.Definition: { } definition } && definition.IsArchangelQuestItem())
{
this._questItemOwner = args.Picker;
await this.ForEachPlayerAsync(player => player.ShowLocalizedGoldenMessageAsync(
nameof(PlayerMessage.BloodCastleArchangelAquiredMessageFormat),
args.Picker.Name,
definition.Name.GetTranslation(player.Culture))
.AsTask())
.ConfigureAwait(false);
}
}
/// <inheritdoc />
protected override async ValueTask OnGameStartAsync(ICollection<Player> players)
{
foreach (var player in players)
{
this._gameStates.TryAdd(player.Name, new PlayerGameState(player));
}
_ = Task.Run(async () => await this.ShowRemainingTimeLoopAsync(this.GameEndedToken).ConfigureAwait(false), this.GameEndedToken);
await base.OnGameStartAsync(players).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask GameEndedAsync(ICollection<Player> finishers)
{
await this.UpdateStateForAllAsync(BloodCastleStatus.Ended).ConfigureAwait(false);
var sortedFinishers = finishers
.Select(f => this._gameStates[f.Name])
.WhereNotNull()
.OrderByDescending(state => state.Score)
.ToList();
var scoreList = new List<(string Name, int Score, int BonusExp, int BonusMoney)>();
int rank = 0;
foreach (var state in sortedFinishers)
{
rank++;
state.Rank = rank;
var (bonusScore, givenMoney) = await this.GiveRewardsAndGetBonusScoreAsync(state.Player, rank).ConfigureAwait(false);
state.AddScore(bonusScore);
scoreList.Add((
state.Player.Name,
state.Score,
this.Definition.Rewards.FirstOrDefault(r => r.RewardType == MiniGameRewardType.Experience && (r.Rank is null || r.Rank == rank))?.RewardAmount ?? 0,
givenMoney));
await this.TryRemoveQuestItemFromPlayerAsync(state.Player).ConfigureAwait(false);
}
this._highScoreTable = scoreList.AsReadOnly();
await this.SaveRankingAsync(sortedFinishers.Select(state => (state.Rank, state.Player.SelectedCharacter!, state.Score))).ConfigureAwait(false);
await base.GameEndedAsync(finishers).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask ShowScoreAsync(Player player)
{
if (this._highScoreTable is { } table)
{
var isSuccessful = this._winner is not null;
var (name, score, bonusMoney, bonusExp) = table.First(t => t.Name == player.Name);
await player.InvokeViewPlugInAsync<IBloodCastleScoreTableViewPlugin>(p => p.ShowScoreTableAsync(isSuccessful, name, score, bonusExp, bonusMoney)).ConfigureAwait(false);
}
}
/// <inheritdoc />
protected override void OnItemDroppedOnMap(DroppedItem item)
{
base.OnItemDroppedOnMap(item);
if (item.Item.Definition.IsArchangelQuestItem())
{
this._questItem = item.Item;
}
}
private async ValueTask ShowRemainingTimeLoopAsync(CancellationToken cancellationToken)
{
try
{
var timerInterval = TimeSpan.FromSeconds(1);
using var timer = new PeriodicTimer(timerInterval);
var maximumGameDuration = this.Definition.GameDuration;
this._remainingTime = maximumGameDuration;
await this.UpdateStateForAllAsync(BloodCastleStatus.Started).ConfigureAwait(false);
while (!cancellationToken.IsCancellationRequested
&& this._remainingTime >= TimeSpan.Zero
&& await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
if (this._remainingTime < maximumGameDuration && !this._gateDestroyed)
{
await this.UpdateStateForAllAsync(BloodCastleStatus.GateNotDestroyed).ConfigureAwait(false);
}
if (this._remainingTime < maximumGameDuration && this._gateDestroyed)
{
await this.UpdateStateForAllAsync(BloodCastleStatus.GateDestroyed).ConfigureAwait(false);
}
this._remainingTime = this._remainingTime.Subtract(timerInterval);
}
}
catch (OperationCanceledException)
{
// Expected exception when the game ends before running into the timeout.
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Unexpected error during update blood castle status: {0}", ex.Message);
}
}
private ValueTask UpdateStateForAllAsync(BloodCastleStatus status)
{
return this.ForEachPlayerAsync(player => this.UpdateStateAsync(status, player).AsTask());
}
private ValueTask UpdateStateAsync(BloodCastleStatus status, Player player)
{
return player.InvokeViewPlugInAsync<IBloodCastleStateViewPlugin>(
p =>
p.UpdateStateAsync(
status,
this._remainingTime,
this.NextEvent?.RequiredKills ?? 0,
this.NextEvent?.ActualKills ?? 0,
this._questItemOwner,
this._questItem));
}
private async ValueTask<bool> TryRemoveQuestItemFromPlayerAsync(Player player)
{
if (!player.TryGetQuestItem(out var item))
{
return false;
}
await player.DestroyInventoryItemAsync(item).ConfigureAwait(false);
this._questItem = null;
this._questItemOwner = null;
return true;
}
private async ValueTask TryDropQuestItemFromPlayerAsync(Player player)
{
if (!player.TryGetQuestItem(out var item))
{
return;
}
var dropped = new DroppedItem(item, player.Position, this.Map, player);
await this.Map.AddAsync(dropped).ConfigureAwait(false);
await player.Inventory!.RemoveItemAsync(item).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IItemDropResultPlugIn>(p => p.ItemDropResultAsync(item.ItemSlot, true)).ConfigureAwait(false);
this._questItemOwner = null;
}
private sealed class PlayerGameState
{
private int _score;
public PlayerGameState(Player player)
{
if (player.SelectedCharacter?.CharacterClass is null)
{
throw new InvalidOperationException($"The player '{player}' is in the wrong state");
}
this.Player = player;
}
public Player Player { get; }
public int Score => this._score;
public int Rank { get; set; }
public void AddScore(int value)
{
Interlocked.Add(ref this._score, value);
}
}
}

View File

@@ -0,0 +1,52 @@
// <copyright file="BloodCastleItemExtensions.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System.Diagnostics.CodeAnalysis;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// Item-related extension methods for the blood castle event.
/// </summary>
public static class BloodCastleItemExtensions
{
private static readonly (short Group, short Number) ArchangelQuestItemId = (13, 19);
/// <summary>
/// Determines whether an item definition is that of the archangel quest item.
/// </summary>
/// <param name="itemDefinition">The item definition.</param>
/// <returns>
/// <c>true</c> if the specified item definition is the archangel quest item; otherwise, <c>false</c>.
/// </returns>
public static bool IsArchangelQuestItem(this ItemDefinition? itemDefinition)
{
return itemDefinition?.Group == ArchangelQuestItemId.Group && itemDefinition.Number == ArchangelQuestItemId.Number;
}
/// <summary>
/// Determines whether the item is the archangel quest item.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>
/// <c>true</c> if the specified item is the archangel quest item; otherwise, <c>false</c>.
/// </returns>
public static bool IsArchangelQuestItem(this Item item)
{
return item.Definition?.IsArchangelQuestItem() ?? false;
}
/// <summary>
/// Tries to get the quest item from the players inventory.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="item">The quest item.</param>
/// <returns>The success.</returns>
public static bool TryGetQuestItem(this Player player, [MaybeNullWhen(false)] out Item item)
{
item = player.Inventory!.Items.FirstOrDefault(i => i.IsArchangelQuestItem());
return item is not null;
}
}

View File

@@ -0,0 +1,663 @@
// <copyright file="ChaosCastleContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System;
using System.Collections.Concurrent;
using System.Threading;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.Pathfinding;
using Nito.Disposables.Internals;
/// <summary>
/// The context of a chaos castle game.
/// </summary>
/// <remarks>
/// A chaos castle event works like that:
/// First, one to 70 players enter the chaos castle.
/// The map starts as a safezone.
/// Then the event starts:
/// * The map terrain is redefined of not being a safezone anymore.
/// * The map is filled up to 100 objects with NPCs
/// Then, these objects fight each other. If the player dies, the event is over for that player.
/// If a npc dies, it doesn't re-spawn. There is a 50% chance, that a died monster explodes and moves players nearby (range 3)
/// by <see cref="BlowOutDistance"/>, depending on the distance to the killed monster.
/// During the game, the map is getting smaller (so-called "Trap Status") after a certain amount of objects died
/// at the following number of remaining objects:
/// * less than 40
/// * less than 30
/// * less than 20
/// A player has won, if:
/// 1) It's the last remaining object on the map
/// 2) The time is up and it has the highest kill count
/// Rewards: A jewel and/or an ancient item.
///
/// Different to the original chaos castle, we don't apply additional damage when players get moved.
/// </remarks>
public sealed class ChaosCastleContext : MiniGameContext
{
private const int MaxObjectsCount = 100;
private const int MonsterKillPoints = 2;
private const int PlayerKillPoints = 1;
private static readonly (int Min, int Max)[] BlowOutDistance =
{
(3, 4),
(3, 4),
(2, 3),
(0, 1),
};
private readonly IGameContext _gameContext;
private readonly IMapInitializer _mapInitializer;
private readonly ConcurrentDictionary<string, PlayerGameState> _gameStates = new();
private readonly ConcurrentDictionary<Monster, byte> _monsters = new();
private IReadOnlyCollection<(string Name, int Score, int BonusExp, int BonusMoney)>? _highScoreTable;
private TimeSpan _remainingTime;
private Player? _winner;
private int _aliveMonstersCount;
private ChaosCastleStatus _currentCastleStatus = ChaosCastleStatus.Running;
/// <summary>
/// Initializes a new instance of the <see cref="ChaosCastleContext"/> class.
/// </summary>
/// <param name="key">The key of this context.</param>
/// <param name="definition">The definition of the mini game.</param>
/// <param name="gameContext">The game context, to which this game belongs.</param>
/// <param name="mapInitializer">The map initializer, which is used when the event starts.</param>
public ChaosCastleContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
this._gameContext = gameContext;
this._mapInitializer = mapInitializer;
this.Logger.LogDebug("Event {0} created, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
}
/// <inheritdoc />
public override bool AllowPlayerKilling => true;
/// <inheritdoc />
protected override Player? Winner => this._winner;
/// <inheritdoc />
protected override TimeSpan RemainingTime => this._remainingTime;
/// <inheritdoc />
protected override int MinimumPlayerCount => 1;
/// <inheritdoc />
public override bool IsItemAllowedToEquip(Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
switch (definition.Group, definition.Number)
{
case (13, 2): // Uniria
case (13, 3): // Dino
case (13, 37): // Fenrir
return false;
default:
if (definition.BasePowerUpAttributes.Any(a => a.TargetAttribute == Stats.TransformationSkin))
{
return false;
}
break;
}
return base.IsItemAllowedToEquip(item);
}
/// <inheritdoc />
public override bool IsSkillAllowed(Skill skill, Player attacker, IAttackable target)
{
if (!base.IsSkillAllowed(skill, attacker, target))
{
return false;
}
if (skill.SkillType == SkillType.SummonMonster)
{
// It's not allowed to summon a monster.
return false;
}
if (skill.SkillType == SkillType.Buff && target != attacker)
{
// It's only allowed to buff the own player.
return false;
}
return true;
}
/// <inheritdoc/>
protected override async ValueTask OnObjectRemovedFromMapAsync((GameMap Map, ILocateable Object) args)
{
if (args.Object is Player player)
{
await this.UpdateStateAsync(ChaosCastleStatus.Ended, player, 0).ConfigureAwait(false);
}
await base.OnObjectRemovedFromMapAsync(args).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask OnTerrainChangingAsync(MiniGameChangeEvent changeEvent)
{
await base.OnTerrainChangingAsync(changeEvent).ConfigureAwait(false);
this.Logger.LogDebug("Terrain changing by event index {0}. Event: {1}, game id {2}", changeEvent.Index, this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
switch (changeEvent.Index)
{
case 1:
this._currentCastleStatus = ChaosCastleStatus.RunningShrinkingStageOne;
break;
case 2:
this._currentCastleStatus = ChaosCastleStatus.RunningShrinkingStageTwo;
break;
case 3:
this._currentCastleStatus = ChaosCastleStatus.RunningShrinkingStageThree;
break;
default:
// no action required
break;
}
}
/// <inheritdoc />
protected override async ValueTask OnTerrainChangedAsync(MiniGameChangeEvent changeEvent)
{
await base.OnTerrainChangedAsync(changeEvent).ConfigureAwait(false);
if (changeEvent.TerrainChanges.All(c => c.TerrainAttribute != TerrainAttributeType.NoGround))
{
return;
}
await this.CheckForFallenUsersAsync().ConfigureAwait(false);
await this.PullMonstersAsync(changeEvent).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask OnGameStartAsync(ICollection<Player> players)
{
this.Logger.LogDebug("Starting the game... Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
foreach (var player in players)
{
this._gameStates.TryAdd(player.Name, new PlayerGameState(player));
}
await base.OnGameStartAsync(players).ConfigureAwait(false);
await this.SpawnMonstersAsync().ConfigureAwait(false);
_ = Task.Run(async () => await this.EventLoopAsync(this.GameEndedToken).ConfigureAwait(false), this.GameEndedToken);
}
/// <inheritdoc />
protected override async ValueTask GameEndedAsync(ICollection<Player> finishers)
{
this.Logger.LogDebug("Game ended... Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
this._currentCastleStatus = ChaosCastleStatus.Ended;
await this.UpdateStateForAllAsync().ConfigureAwait(false);
var sortedFinishers = finishers
.Select(f => this._gameStates[f.Name])
.WhereNotNull()
.OrderByDescending(state => state.Score)
.ToList();
var scoreList = new List<(string Name, int Score, int BonusExp, int BonusMoney)>();
int rank = 0;
foreach (var state in sortedFinishers)
{
this._winner ??= state.Player;
rank++;
state.Rank = rank;
var (bonusScore, givenMoney) = await this.GiveRewardsAndGetBonusScoreAsync(state.Player, rank).ConfigureAwait(false);
state.AddScore(bonusScore);
scoreList.Add((
state.Player.Name,
state.Score,
this.Definition.Rewards.FirstOrDefault(r => r.RewardType == MiniGameRewardType.Experience && (r.Rank is null || r.Rank == rank))?.RewardAmount ?? 0,
givenMoney));
}
this._highScoreTable = scoreList.AsReadOnly();
await this.SaveRankingAsync(sortedFinishers.Select(state => (state.Rank, state.Player.SelectedCharacter!, state.Score))).ConfigureAwait(false);
await base.GameEndedAsync(finishers).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask ShowScoreAsync(Player player)
{
if (this._highScoreTable is { } table)
{
var isSuccessful = this._winner is not null;
var (name, score, bonusMoney, bonusExp) = table.First(t => t.Name == player.Name);
await player.InvokeViewPlugInAsync<IBloodCastleScoreTableViewPlugin>(p => p.ShowScoreTableAsync(isSuccessful, name, score, bonusExp, bonusMoney)).ConfigureAwait(false);
}
}
/// <inheritdoc />
protected override void OnPlayerDied(object? sender, DeathInformation e)
{
base.OnPlayerDied(sender, e);
if (this._gameStates.TryGetValue(e.KillerName, out var playerState))
{
playerState.AddScore(PlayerKillPoints);
}
}
#pragma warning disable VSTHRD100 // Avoid async void methods
/// <inheritdoc />
protected override async void OnMonsterDied(object? sender, DeathInformation e)
#pragma warning restore VSTHRD100 // Avoid async void methods
{
try
{
Interlocked.Decrement(ref this._aliveMonstersCount);
base.OnMonsterDied(sender, e);
if (sender is not Monster monster)
{
return;
}
this._monsters.Remove(monster, out _);
if (this._gameStates.TryGetValue(e.KillerName, out var playerState))
{
playerState.AddScore(MonsterKillPoints);
}
// There is a 50 % chance, that a died monster explodes and moves players nearby(range 3) by one coordinate.
if (Rand.NextRandomBool(50))
{
return;
}
var position = monster.Position;
var playersInRange = monster.CurrentMap.GetAttackablesInRange(position, 3).OfType<Player>();
foreach (var player in playersInRange)
{
await this.MovePlayerByExplosionAsync(player, position).ConfigureAwait(false);
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Unexpected Error when handling a monster death.");
}
}
// see also BlowObjsFromPoint
private async ValueTask MovePlayerByExplosionAsync(Player player, Point explosionPosition)
{
var playerPosition = player.Position;
var distance = (int)playerPosition.EuclideanDistanceTo(explosionPosition);
if (distance > 3)
{
return;
}
int directionX;
int directionY;
if (playerPosition.X > explosionPosition.X)
{
directionX = 1;
}
else if (playerPosition.X < explosionPosition.X)
{
directionX = -1;
}
else
{
directionX = Rand.NextRandomBool() ? 1 : -1;
}
if (playerPosition.Y > explosionPosition.Y)
{
directionY = 1;
}
else if (playerPosition.Y < explosionPosition.Y)
{
directionY = -1;
}
else
{
directionY = Rand.NextRandomBool() ? 1 : -1;
}
for (int i = 0; i < 5; i++)
{
var (min, max) = BlowOutDistance[distance];
var blowX = Rand.NextInt(min, max + 1);
var blowY = Rand.NextInt(min, max + 1);
if (Rand.NextRandomBool())
{
if (blowX >= max)
{
blowX = max;
blowY = min - Rand.NextInt(0, 2);
}
}
else if (blowY >= max)
{
blowY = max;
blowX = min - Rand.NextInt(0, 2);
}
else
{
// leave the random values like they are
}
blowX = Math.Max(0, blowX);
blowY = Math.Max(0, blowY);
var targetX = playerPosition.X + (blowX * directionX);
var targetY = playerPosition.Y + (blowY * directionY);
targetX = Math.Max(0, targetX);
targetX = Math.Min(0xFF, targetX);
targetY = Math.Max(0, targetY);
targetY = Math.Min(0xFF, targetY);
var targetPoint = new Point((byte)targetX, (byte)targetY);
var moved = await this.SetPlayerPositionAsync(player, targetPoint).ConfigureAwait(false);
if (moved)
{
this.Logger.LogDebug("Player {player} was moved to {targetPoint}.", player, targetPoint);
await this.CheckPlayerPositionAsync(player).ConfigureAwait(false);
break;
}
}
}
private async ValueTask<bool> SetPlayerPositionAsync(Player player, Point point)
{
if (this.Map != player.CurrentMap)
{
return false;
}
if (player.IsTeleporting)
{
return true;
}
await player.MoveAsync(point).ConfigureAwait(false);
return true;
}
private async ValueTask EventLoopAsync(CancellationToken cancellationToken)
{
try
{
this.Logger.LogDebug("Starting event loop... Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
var timerInterval = TimeSpan.FromSeconds(1);
using var timer = new PeriodicTimer(timerInterval);
var maximumGameDuration = this.Definition.GameDuration;
var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
cts.CancelAfter(maximumGameDuration);
var ending = DateTime.UtcNow.Add(maximumGameDuration);
this._remainingTime = maximumGameDuration;
this._currentCastleStatus = ChaosCastleStatus.Started;
await this.UpdateStateForAllAsync().ConfigureAwait(false);
this._currentCastleStatus = ChaosCastleStatus.Running;
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
cancellationToken.ThrowIfCancellationRequested();
await this.UpdateStateForAllAsync().ConfigureAwait(false);
if (this.HasGameEnded())
{
this.FinishEvent();
break;
}
this._remainingTime = ending.Subtract(DateTime.UtcNow);
}
this._remainingTime = TimeSpan.Zero;
}
catch (OperationCanceledException ex)
{
// Expected exception when the game ends before running into the timeout.
this.Logger.LogDebug(ex, "Stopped Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "Unexpected error during update chaos castle status: {0}", ex.Message);
}
finally
{
this._remainingTime = TimeSpan.Zero;
this._currentCastleStatus = ChaosCastleStatus.Ended;
}
}
private bool HasGameEnded()
{
var playerCount = this.PlayerCount;
if (playerCount <= 0)
{
this.Logger.LogDebug("Game ended - all players dead. Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
return true;
}
if (playerCount == 1 && this._aliveMonstersCount == 0)
{
this.Logger.LogDebug("Game ended - last player remaining. Event: {0}, game id {1}", this.Definition.Name, (this._gameContext as IGameServerContext)?.Id ?? 0);
return true;
}
return false;
}
private async ValueTask CheckForFallenUsersAsync()
{
// Players which are on a terrain without ground, fall down
await this.ForEachPlayerAsync(this.CheckPlayerPositionAsync).ConfigureAwait(false);
}
private async Task CheckPlayerPositionAsync(Player player)
{
var position = player.Position;
var terrainIsWalkable = this.Map.Terrain.WalkMap[position.X, position.Y];
if (terrainIsWalkable)
{
return;
}
this.Logger.LogDebug("Player {0} is at a blocked position, it will be killed instantly.", player);
await player.KillInstantlyAsync().ConfigureAwait(false);
}
private async ValueTask UpdateStateForAllAsync()
{
var objectCount = this._aliveMonstersCount + this.PlayerCount;
var currentStatus = this._currentCastleStatus;
this.Logger.LogDebug("UpdateState {0} for all players. Object Count: {1}", currentStatus, objectCount);
await this.ForEachPlayerAsync(player => this.UpdateStateAsync(currentStatus, player, objectCount)).ConfigureAwait(false);
if (currentStatus is ChaosCastleStatus.RunningShrinkingStageOne or ChaosCastleStatus.RunningShrinkingStageTwo or ChaosCastleStatus.RunningShrinkingStageThree)
{
await this.ForEachPlayerAsync(player => this.UpdateStateAsync(ChaosCastleStatus.Running, player, objectCount)).ConfigureAwait(false);
}
}
private async Task UpdateStateAsync(ChaosCastleStatus status, Player player, int objectCount)
{
await player.InvokeViewPlugInAsync<IChaosCastleStateViewPlugin>(
p =>
p.UpdateStateAsync(
status,
this._remainingTime,
MaxObjectsCount,
objectCount))
.ConfigureAwait(false);
}
private async Task SpawnMonstersAsync()
{
var requiredMonsters = MaxObjectsCount - this.PlayerCount;
this.Logger.LogDebug("{0}: Spawning {1} monsters...", this.Definition.Description, requiredMonsters);
var spawnAreas = this.Map.Definition.MonsterSpawns.AsList();
this.DropGenerator = new ChaosCastleDropGenerator(this._gameContext, this, requiredMonsters);
for (var i = 0; i < requiredMonsters && i < spawnAreas.Count; i++)
{
var spawnArea = spawnAreas[i];
if (await this._mapInitializer.InitializeSpawnAsync(i, this.Map, spawnArea, this, this.DropGenerator).ConfigureAwait(false) is Monster monster)
{
this._monsters.TryAdd(monster, default);
}
}
this._aliveMonstersCount = requiredMonsters;
this.Logger.LogDebug("Monsters created.");
}
private async ValueTask PullMonstersAsync(MiniGameChangeEvent changeEvent)
{
// Determine middle:
var minX = changeEvent.TerrainChanges.Min(c => c.StartX);
var maxX = changeEvent.TerrainChanges.Max(c => c.EndX);
var minY = changeEvent.TerrainChanges.Min(c => c.StartY);
var maxY = changeEvent.TerrainChanges.Max(x => x.EndY);
var middlePoint = new Point((byte)((minX + maxX) / 2), (byte)((minY + maxY) / 2));
var walkMap = this.Map.Terrain.WalkMap;
var monstersNeedPull = this._monsters.Keys
.Where(m => !walkMap[m.Position.X, m.Position.Y])
.Where(m => m.IsAlive);
bool MoveByX(byte distance, ref Point resultTarget, bool setTargetAnyway = false)
{
var offset = new Point(0, distance);
var target = resultTarget;
if (target.Y < middlePoint.Y)
{
target += offset;
}
else if (target.Y > middlePoint.Y)
{
target -= offset;
}
else
{
// we're already good to go!
}
return EvaluateTarget(target, ref resultTarget, setTargetAnyway);
}
bool MoveByY(byte distance, ref Point resultTarget, bool setTargetAnyway = false)
{
var offset = new Point(distance, 0);
var target = resultTarget;
if (target.X < middlePoint.X)
{
target += offset;
}
else if (target.X > middlePoint.X)
{
target -= offset;
}
else
{
// we're already good to go!
}
return EvaluateTarget(target, ref resultTarget, setTargetAnyway);
}
bool MoveByXandY(byte distance, ref Point resultTarget)
{
var target = resultTarget;
MoveByX(distance, ref target, true);
MoveByY(distance, ref target, true);
return EvaluateTarget(target, ref resultTarget, false);
}
bool EvaluateTarget(Point target, ref Point resultTarget, bool setTargetAnyway)
{
if (walkMap[target.X, target.Y])
{
resultTarget = target;
return true;
}
if (setTargetAnyway)
{
resultTarget = target;
}
return false;
}
foreach (var monster in monstersNeedPull)
{
var moved = false;
for (byte dist = 1; dist <= 5; dist++)
{
var target = monster.Position;
if (MoveByX(dist, ref target)
|| MoveByY(dist, ref target)
|| MoveByXandY(dist, ref target))
{
this.Logger.LogDebug("Moving {monster} by {dist} steps to {target}", monster, dist, target);
await monster.MoveAsync(target).ConfigureAwait(false);
moved = true;
break;
}
}
if (!moved)
{
this.Logger.LogDebug("Couldn't move monster {monster} to valid coordinate.", monster);
}
}
}
private sealed class PlayerGameState
{
private int _score;
public PlayerGameState(Player player)
{
if (player.SelectedCharacter?.CharacterClass is null)
{
throw new InvalidOperationException($"The player '{player}' is in the wrong state");
}
this.Player = player;
}
public Player Player { get; }
public int Score => this._score;
public int Rank { get; set; }
public void AddScore(int value)
{
Interlocked.Add(ref this._score, value);
}
}
}

View File

@@ -0,0 +1,95 @@
// <copyright file="ChaosCastleDropGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System.Collections.Immutable;
using System.Threading;
using MUnique.OpenMU.DataModel.Configuration.Items;
/// <summary>
/// An <see cref="IDropGenerator"/> especially for Chaos Castle.
/// It will drop a predefined amount of jewels during the whole event,
/// and nothing more.
/// </summary>
public class ChaosCastleDropGenerator : IDropGenerator
{
private static readonly IImmutableList<(int Blesses, int Souls)> MonsterJewelDropsPerLevel = new List<(int Blesses, int Souls)>
{
new(0, 0), // Dummy
new(0, 2), // Chaos Castle 1
new(1, 1), // Chaos Castle 2
new(1, 2), // Chaos Castle 3
new(1, 2), // Chaos Castle 4
new(2, 1), // Chaos Castle 5
new(2, 2), // Chaos Castle 6
new(2, 3), // Chaos Castle 7
}.ToImmutableList();
private readonly Dictionary<int, ItemDefinition> _monsterDrops = new();
private readonly IGameContext _gameContext;
private int _killedMonsters;
/// <summary>
/// Initializes a new instance of the <see cref="ChaosCastleDropGenerator"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="context">The context.</param>
/// <param name="spawnedMonstersCount">The spawned monsters count.</param>
public ChaosCastleDropGenerator(IGameContext gameContext, ChaosCastleContext context, int spawnedMonstersCount)
{
this._gameContext = gameContext;
// TODO: This should rather be configurable...
var blessDefinition = this._gameContext.Configuration.Items.First(item => item is { Group: 14, Number: 13 });
var soulDefinition = this._gameContext.Configuration.Items.First(item => item is { Group: 14, Number: 14 });
var drops = MonsterJewelDropsPerLevel[context.Definition.GameLevel];
AddItems(drops.Blesses, blessDefinition);
AddItems(drops.Souls, soulDefinition);
void AddItems(int count, ItemDefinition definition)
{
for (var i = 0; i < count; i++)
{
bool assigned;
do
{
var randomKillCount = Rand.NextInt(1, spawnedMonstersCount + 1);
assigned = this._monsterDrops.TryAdd(randomKillCount, definition);
}
while (!assigned);
}
}
}
/// <inheritdoc />
public async ValueTask<(IEnumerable<Item> Items, uint? Money)> GenerateItemDropsAsync(MonsterDefinition monster, int gainedExperience, Player player)
{
var killCount = Interlocked.Increment(ref this._killedMonsters);
if (this._monsterDrops.TryGetValue(killCount, out var drop))
{
var item = new TemporaryItem
{
Definition = drop,
Durability = 1,
};
return (item.GetAsEnumerable(), null);
}
return (Enumerable.Empty<Item>(), null);
}
/// <inheritdoc />
public Item? GenerateItemDrop(DropItemGroup group)
{
return this._gameContext.DropGenerator.GenerateItemDrop(group);
}
/// <inheritdoc />
public (Item? Item, uint? Money, ItemDropEffect DropEffect) GenerateItemDrop(IEnumerable<DropItemGroup> groups)
{
return this._gameContext.DropGenerator.GenerateItemDrop(groups);
}
}

View File

@@ -0,0 +1,41 @@
// <copyright file="ChaosCastleStatus.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
/// <summary>
/// The status of a blood castle event.
/// </summary>
public enum ChaosCastleStatus
{
/// <summary>
/// The event has just started and is running.
/// </summary>
Started,
/// <summary>
/// The event is running, no terrain shrinking applied yet.
/// </summary>
Running,
/// <summary>
/// The event is running, first terrain shrinking applied.
/// </summary>
RunningShrinkingStageOne,
/// <summary>
/// The event is running, second terrain shrinking applied.
/// </summary>
RunningShrinkingStageTwo,
/// <summary>
/// The event is running, third (final) terrain shrinking applied.
/// </summary>
RunningShrinkingStageThree,
/// <summary>
/// The event has ended.
/// </summary>
Ended,
}

View File

@@ -0,0 +1,121 @@
// <copyright file="DevilSquareContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System.Collections.Concurrent;
using System.Threading;
using Nito.Disposables.Internals;
/// <summary>
/// The context of a devil square game.
/// </summary>
public sealed class DevilSquareContext : MiniGameContext
{
private readonly ConcurrentDictionary<string, PlayerGameState> _gameStates = new();
private IReadOnlyCollection<(string Name, int Score, int BonusMoney, int BonusExp)>? _highScoreTable;
/// <summary>
/// Initializes a new instance of the <see cref="DevilSquareContext"/> class.
/// </summary>
/// <param name="key">The key of this context.</param>
/// <param name="definition">The definition of the mini game.</param>
/// <param name="gameContext">The game context, to which this game belongs.</param>
/// <param name="mapInitializer">The map initializer, which is used when the event starts.</param>
public DevilSquareContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
}
/// <inheritdoc />
protected override void OnMonsterDied(object? sender, DeathInformation e)
{
base.OnMonsterDied(sender, e);
if (this._gameStates.TryGetValue(e.KillerName, out var state))
{
state.AddScore(this.Definition.GameLevel);
// todo add money? -> in the original servers, in DS drops no money!
}
}
/// <inheritdoc />
protected override async ValueTask OnGameStartAsync(ICollection<Player> players)
{
foreach (var player in players)
{
this._gameStates.TryAdd(player.Name, new PlayerGameState(player));
}
await base.OnGameStartAsync(players).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask GameEndedAsync(ICollection<Player> finishers)
{
var sortedFinishers = finishers
.Select(f => this._gameStates[f.Name])
.WhereNotNull()
.OrderBy(state => state.Score)
.ToList();
var scoreList = new List<(string Name, int Score, int BonusMoney, int BonusExp)>();
int rank = 0;
foreach (var state in sortedFinishers)
{
rank++;
state.Rank = rank;
var (bonusScore, givenMoney) = await this.GiveRewardsAndGetBonusScoreAsync(state.Player, rank).ConfigureAwait(false);
state.AddScore(bonusScore);
scoreList.Add((
state.Player.Name,
state.Score,
givenMoney,
this.Definition.Rewards.FirstOrDefault(r => r.RewardType == MiniGameRewardType.Experience && (r.Rank is null || r.Rank == rank))?.RewardAmount ?? 0));
}
this._highScoreTable = scoreList.AsReadOnly();
await this.SaveRankingAsync(sortedFinishers.Select(state => (state.Rank, state.Player.SelectedCharacter!, state.Score))).ConfigureAwait(false);
await base.GameEndedAsync(finishers).ConfigureAwait(false);
}
/// <inheritdoc />
protected override async ValueTask ShowScoreAsync(Player player)
{
if (this._highScoreTable is { } table
&& this._gameStates.TryGetValue(player.Name, out var state))
{
await player.InvokeViewPlugInAsync<IMiniGameScoreTableViewPlugin>(p => p.ShowScoreTableAsync((byte)state.Rank, table)).ConfigureAwait(false);
}
}
private sealed class PlayerGameState
{
private int _score;
public PlayerGameState(Player player)
{
if (player.SelectedCharacter?.CharacterClass is null)
{
throw new InvalidOperationException($"The player '{player}' is in the wrong state");
}
this.Player = player;
}
public Player Player { get; }
public int Score => this._score;
public int Rank { get; set; }
public void AddScore(int value)
{
Interlocked.Add(ref this._score, value);
}
}
}

View File

@@ -0,0 +1,23 @@
// <copyright file="IBloodCastleScoreTableViewPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Views;
/// <summary>
/// Interface of a view whose implementation informs about the score table of a blood castle event.
/// </summary>
public interface IBloodCastleScoreTableViewPlugin : IViewPlugIn
{
/// <summary>
/// Shows the score table to the player.
/// </summary>
/// <param name="success">The success.</param>
/// <param name="playerName">The player name.</param>
/// <param name="totalScore">The total score.</param>
/// <param name="bonusExp">The bonus experience.</param>
/// <param name="bonusMoney">The bonus money.</param>
ValueTask ShowScoreTableAsync(bool success, string playerName, int totalScore, int bonusExp, int bonusMoney);
}

View File

@@ -0,0 +1,50 @@
// <copyright file="IBloodCastleStateViewPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Views;
/// <summary>
/// The status of a blood castle event.
/// </summary>
public enum BloodCastleStatus
{
/// <summary>
/// The event has just started and is running.
/// </summary>
Started,
/// <summary>
/// The event is running, but the gate is not destroyed.
/// </summary>
GateNotDestroyed,
/// <summary>
/// The event is running and the gate is destroyed.
/// </summary>
GateDestroyed,
/// <summary>
/// The event has ended.
/// </summary>
Ended,
}
/// <summary>
/// Interface of a view whose implementation informs about the status of a blood castle event.
/// </summary>
public interface IBloodCastleStateViewPlugin : IViewPlugIn
{
/// <summary>
/// Update the state of the blood castle event.
/// </summary>
/// <param name="status">The status of the blood castle event.</param>
/// <param name="remainingTime">The remaining time of the blood castle event.</param>
/// <param name="maxMonster">Maximum number of monsters to kill.</param>
/// <param name="curMonster">Current number of monsters killed.</param>
/// <param name="questItemOwner">The player which picked up the quest item.</param>
/// <param name="questItem">The quest item which was dropped by the statue.</param>
ValueTask UpdateStateAsync(BloodCastleStatus status, TimeSpan remainingTime, int maxMonster, int curMonster, IIdentifiable? questItemOwner, Item? questItem);
}

View File

@@ -0,0 +1,21 @@
// <copyright file="IChangeTerrainAttributesViewPlugin.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using MUnique.OpenMU.GameLogic.Views;
/// <summary>
/// Interface for view plugins which update the terrain attributes.
/// </summary>
public interface IChangeTerrainAttributesViewPlugin : IViewPlugIn
{
/// <summary>
/// Updates the terrain attributes.
/// </summary>
/// <param name="attribute">The type of terrain attribute.</param>
/// <param name="setAttribute">Specifies, if the attribute should be set (true), or removed (false).</param>
/// <param name="areas">The areas of terrain.</param>
ValueTask ChangeAttributesAsync(TerrainAttributeType attribute, bool setAttribute, IReadOnlyCollection<(byte StartX, byte StartY, byte EndX, byte EndY)> areas);
}

Some files were not shown because too many files have changed in this diff Show More