Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
976b0d8
feature: offline party support
eduardosmaniotto Mar 16, 2026
8dd0140
Merge branch 'master' into feature/offlevel-party
eduardosmaniotto Mar 16, 2026
d1d36f8
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 16, 2026
a7a06e4
feature: offline party support bug fixing
eduardosmaniotto Mar 17, 2026
353308d
fix: incorrect packet size causing test failure
eduardosmaniotto Mar 17, 2026
c6a678a
offlevel: implemented missing combat behaviour
eduardosmaniotto Mar 17, 2026
5663e3c
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 18, 2026
454cb08
code review: minor adjustments
eduardosmaniotto Mar 18, 2026
96829fd
feature: offline leveling pet support and some minor adjustments
eduardosmaniotto Mar 18, 2026
a04bc85
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 18, 2026
721a7e3
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 18, 2026
c98e22a
fix: potential race condition on offline leveling login
eduardosmaniotto Mar 19, 2026
32c9beb
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 21, 2026
368e499
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 21, 2026
b91d7ca
fix: stop pet when player dies
eduardosmaniotto Mar 21, 2026
ef0154b
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 21, 2026
2c69d1f
Merge branch 'feature/offlevel' into feature/offlevel-party
eduardosmaniotto Mar 21, 2026
bb0504d
code review: fix file formatting
eduardosmaniotto Mar 21, 2026
1168dcb
feature: offline leveling test methods
eduardosmaniotto Mar 21, 2026
5b3ac10
fix: revert breaking change
eduardosmaniotto Mar 22, 2026
18dc98d
code review: apply code suggestions
eduardosmaniotto Mar 22, 2026
bb35fbf
code review: apply code suggestions
eduardosmaniotto Mar 22, 2026
27a2f05
Merge branch 'master' into feature/offlevel-party
sven-n Mar 23, 2026
c870e24
code review: revert code deleted by mistake
eduardosmaniotto Mar 23, 2026
e61f718
code review: fix warnings
eduardosmaniotto Mar 23, 2026
20db643
bugfix: offlevel party
eduardosmaniotto Mar 23, 2026
be1ae3e
code review: fix warnings
eduardosmaniotto Mar 24, 2026
5d5d021
code review: fix warnings
eduardosmaniotto Mar 24, 2026
870ff76
code review: revert changes
eduardosmaniotto Mar 24, 2026
5fcf2b0
code review: apply code changes, revert resources
eduardosmaniotto Mar 24, 2026
def5f49
code review: revert CurrentTarget access modifier
eduardosmaniotto Mar 25, 2026
38c32c3
fix: soul barrier dmg reduction
eduardosmaniotto Mar 25, 2026
c0476bc
refactor: offline party, remove unecessary offline plugins
eduardosmaniotto Mar 26, 2026
54b15ab
code review: fix warnings
eduardosmaniotto Mar 26, 2026
00388aa
code review: revert unecessary changes
eduardosmaniotto Mar 26, 2026
f04bb25
code review: fix warnings
eduardosmaniotto Mar 26, 2026
29f3e7c
code review
eduardosmaniotto Mar 26, 2026
d5cbc3d
add supress warning
eduardosmaniotto Mar 26, 2026
2d62756
remove supress warning and try to fix
eduardosmaniotto Mar 26, 2026
3796572
replace Timer with PeriodicTimer in Party.cs as recommended
eduardosmaniotto Mar 26, 2026
60ed30b
replace Timer with PeriodicTimer in Party.cs as recommended
eduardosmaniotto Mar 26, 2026
82162dd
code review: apply code changes
eduardosmaniotto Mar 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions docs/Packets/C1-11-ObjectHitExtended_by-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ The damage is shown at the object which received the hit.
| 1 | 1 | Byte | 16 | Packet header - length of the packet |
| 2 | 1 | Byte | 0x11 | Packet header - packet type identifier |
| 3 << 0 | 4 bit | DamageKind | | Kind |
| 3 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit |
| 3 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit |
| 3 << 6 | 1 bit | Boolean | | IsDoubleDamage |
| 3 << 7 | 1 bit | Boolean | | IsTripleDamage |
| 4 | 2 | ShortLittleEndian | | ObjectId |
Expand All @@ -34,7 +36,7 @@ Defines the kind of the damage.
| 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. |
| 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. |
| 3 | CriticalBlue | Blue color, usually used by critical damage. |
| 4 | LightPink | Light pink color. |
| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The description for ReflectedLightPink should consistently use 'reflected damage', similar to the other descriptions.

| 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. |
| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. |
| 6 | DarkPink | Dark pink color. |
| 7 | White | White color. |
6 changes: 4 additions & 2 deletions docs/Packets/C1-11-ObjectHit_by-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ The damage is shown at the object which received the hit.
| 3 | 2 | ShortBigEndian | | ObjectId |
| 5 | 2 | ShortBigEndian | | HealthDamage |
| 7 << 0 | 4 bit | DamageKind | | Kind |
| 7 << 4 | 1 bit | Boolean | | IsRageFighterStreakHit |
| 7 << 5 | 1 bit | Boolean | | IsRageFighterStreakFinalHit |
| 7 << 6 | 1 bit | Boolean | | IsDoubleDamage |
| 7 << 7 | 1 bit | Boolean | | IsTripleDamage |
| 8 | 2 | ShortBigEndian | | ShieldDamage |
Expand All @@ -33,7 +35,7 @@ Defines the kind of the damage.
| 1 | IgnoreDefenseCyan | Cyan color, usually used by ignore defense damage. |
| 2 | ExcellentLightGreen | Light green color, usually used by excellent damage. |
| 3 | CriticalBlue | Blue color, usually used by critical damage. |
| 4 | LightPink | Light pink color. |
| 4 | ReflectedLightPink | Light pink color, usually used by reflected damage. |
| 5 | PoisonDarkGreen | Dark green color, usually used by poison damage. |
| 6 | ReflectedDarkPink | Dark pink color, usually used by reflected damage. |
| 6 | DarkPink | Dark pink color. |
| 7 | White | White color. |
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ In case of opening, the server returns if the cash shop is available. If the pla
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 8 | Packet header - length of the packet |
| 1 | 1 | Byte | 10 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x05 | Packet header - sub packet type identifier |
| 4 | 4 | IntegerLittleEndian | | PageIndex |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ The item is applied or added to the inventory.
| Index | Length | Data Type | Value | Description |
|-------|--------|-----------|-------|-------------|
| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
| 1 | 1 | Byte | 5 | Packet header - length of the packet |
| 1 | 1 | Byte | 15 | Packet header - length of the packet |
| 2 | 1 | Byte | 0xD2 | Packet header - packet type identifier |
| 3 | 1 | Byte | 0x0B | Packet header - sub packet type identifier |
| 4 | 4 | IntegerLittleEndian | | BaseItemCode |
Expand Down
2 changes: 1 addition & 1 deletion src/DataModel/Configuration/MonsterDefinition.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <copyright file="MonsterDefinition.cs" company="MUnique">
// <copyright file="MonsterDefinition.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

Expand Down
4 changes: 3 additions & 1 deletion src/GameLogic/AttackableExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ public static async ValueTask<HitInfo> CalculateDamageAsync(this IAttacker attac
if (soulBarrierManaToll > 0 && defender.Attributes[Stats.CurrentMana] > soulBarrierManaToll)
{
manaToll = soulBarrierManaToll;
dmg -= (int)(dmg * defender.Attributes[Stats.SoulBarrierReceiveDecrement]);
float soulBarrierReduction = defender.Attributes[Stats.SoulBarrierReceiveDecrement];
soulBarrierReduction = Math.Clamp(soulBarrierReduction, 0f, 0.9f);
dmg -= (int)(dmg * soulBarrierReduction);
}

dmg += (int)attacker.Attributes[Stats.FinalDamageBonus];
Expand Down
12 changes: 7 additions & 5 deletions src/GameLogic/GameContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <copyright file="GameContext.cs" company="MUnique">
// <copyright file="GameContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

Expand Down Expand Up @@ -80,6 +80,7 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider
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);
Expand Down Expand Up @@ -148,16 +149,17 @@ public GameContext(GameConfiguration configuration, IPersistenceContextProvider
/// <summary>
/// Gets the players by character name dictionary.
/// </summary>
public ConcurrentDictionary<string, Player> PlayersByCharacterName { get; } = new(StringComparer.OrdinalIgnoreCase);
public ConcurrentDictionary<string, Player> PlayersByCharacterName { get; } = new ConcurrentDictionary<string, Player>(StringComparer.OrdinalIgnoreCase);

/// <inheritdoc />
public DuelRoomManager DuelRoomManager { get; set; }

/// <summary>
/// Gets the state of the active self defenses.
/// </summary>
/// <inheritdoc />
public ConcurrentDictionary<(Player Attacker, Player Defender), DateTime> SelfDefenseState { get; } = new();

/// <inheritdoc />
public IPartyManager PartyManager { get; }

/// <inheritdoc />
public ILoggerFactory LoggerFactory { get; }

Expand Down
7 changes: 6 additions & 1 deletion src/GameLogic/IGameContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <copyright file="IGameContext.cs" company="MUnique">
// <copyright file="IGameContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

Expand Down Expand Up @@ -112,6 +112,11 @@ public interface IGameContext
/// </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>
Expand Down
39 changes: 39 additions & 0 deletions src/GameLogic/IPartyManager.cs
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 6 additions & 1 deletion src/GameLogic/IPartyMember.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <copyright file="IPartyMember.cs" company="MUnique">
// <copyright file="IPartyMember.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

Expand Down Expand Up @@ -33,4 +33,9 @@ public interface IPartyMember : IWorldObserver, IObservable, IIdentifiable, ILoc
/// 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; }
}
2 changes: 1 addition & 1 deletion src/GameLogic/MUnique.OpenMU.GameLogic.csproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
Expand Down
2 changes: 1 addition & 1 deletion src/GameLogic/MapInitializer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// <copyright file="MapInitializer.cs" company="MUnique">
// <copyright file="MapInitializer.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>

Expand Down
104 changes: 59 additions & 45 deletions src/GameLogic/NPC/BasicMonsterIntelligence.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,6 @@ public class BasicMonsterIntelligence : INpcIntelligence, IDisposable
/// <summary>
/// Gets or sets 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>
public bool CanWalkOnSafezone { get; protected set; }

/// <inheritdoc/>
Expand Down Expand Up @@ -91,6 +88,13 @@ public virtual bool CanWalkOn(Point target)
return this.Monster.CurrentMap.Terrain.AIgrid[target.X, target.Y] == 1;
}

/// <summary>
/// Determines whether the specified player is being targeted by this monster.
/// </summary>
/// <param name="player">The player to check.</param>
/// <returns>True if the player is the current target; otherwise, false.</returns>
public bool IsTargetingPlayer(Player player) => this.CurrentTarget == player;

/// <summary>
/// Called when the intelligence starts.
/// </summary>
Expand Down Expand Up @@ -129,30 +133,28 @@ protected virtual void Dispose(bool managed)
var possibleTargets = tempObservers.OfType<IAttackable>()
.Where(a => a.IsActive() && !a.IsAtSafezone() && a is not Player { IsInvisible: true })
.ToList();

// Also consider summoned monsters belonging to players in range.
var summons = possibleTargets.OfType<Player>()
.Select(p => p.Summon?.Item1)
.Where(s => s is not null)
.Cast<IAttackable>()
.WhereActive()
.ToList();
possibleTargets.AddRange(summons);
var closestTarget = possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc));

return closestTarget;
possibleTargets.AddRange(summons);

// todo: check the walk distance
return possibleTargets.MinBy(a => a.GetDistanceTo(this.Npc));
}

/// <summary>
/// Determines whether this instance can attack.
/// Determines whether this instance can attack this tick.
/// </summary>
/// <returns>
/// <c>true</c> if this instance can attack; otherwise, <c>false</c>.
/// </returns>
protected virtual ValueTask<bool> CanAttackAsync() => ValueTask.FromResult(true);

/// <summary>
/// Handles the tick without having a target.
/// Handles the tick when no target is available, moves the monster randomly.
/// </summary>
protected virtual async ValueTask TickWithoutTargetAsync()
{
Expand All @@ -161,19 +163,15 @@ protected virtual async ValueTask TickWithoutTargetAsync()
return;
}

// we move around randomly, so the monster does not look dead when watched from distance.
if (await this.IsObservedByAttackerAsync().ConfigureAwait(false))
{
await this.Monster.RandomMoveAsync().ConfigureAwait(false);
}
}

/// <summary>
/// Determines whether the handled monster is observed by an attacker.
/// Determines whether the handled monster is observed by any attacker.
/// </summary>
/// <returns>
/// <c>true</c> if the handled monster is observed by an attacker; otherwise, <c>false</c>.
/// </returns>
protected async ValueTask<bool> IsObservedByAttackerAsync()
{
using var readerLock = await this.Monster.ObserverLock.ReaderLockAsync();
Expand All @@ -197,7 +195,7 @@ private async void SafeTick()
}
catch (OperationCanceledException)
{
// can be ignored.
// expected during shutdown.
}
catch (Exception ex)
{
Expand Down Expand Up @@ -228,36 +226,19 @@ private async ValueTask TickAsync()
return;
}

var target = this.CurrentTarget;
if (target != null)
{
// Old Target out of Range?
if (!target.IsAlive
|| target is Player { IsInvisible: true }
|| target.IsTeleporting
|| target.IsAtSafezone()
|| !target.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange)
|| (target is IWorldObserver && !await this.IsTargetInObserversAsync(target).ConfigureAwait(false)))
{
target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false);
}
}
else
{
target = this.CurrentTarget = await this.SearchNextTargetAsync().ConfigureAwait(false);
}
this.CurrentTarget = await this.ResolveTargetAsync().ConfigureAwait(false);

// no target?
if (target is null)
if (this.CurrentTarget is null)
{
await this.TickWithoutTargetAsync().ConfigureAwait(false);
return;
}

// Target in Attack Range?
if (target.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange) && !this.Monster.IsAtSafezone())
// Target in attack range — attack.
if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.AttackRange)
&& !this.Monster.IsAtSafezone())
{
await this.Monster.AttackAsync(target).ConfigureAwait(false); // yes, attack
await this.Monster.AttackAsync(this.CurrentTarget).ConfigureAwait(false);
return;
}

Expand All @@ -266,18 +247,51 @@ private async ValueTask TickAsync()
return;
}

// Target in View Range?
if (target.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1))
// Target visible but outside attack range, walk toward it.
if (this.CurrentTarget.IsInRange(this.Monster.Position, this.Monster.Definition.ViewRange + 1))
{
// no, walk to the target
var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(target.Position, this.Monster.Definition.AttackRange);
var walkTarget = this.Monster.CurrentMap!.Terrain.GetRandomCoordinate(this.CurrentTarget.Position, this.Monster.Definition.AttackRange);
if (await this.Monster.WalkToAsync(walkTarget).ConfigureAwait(false))
{
return;
}
}

// we move around randomly, so the monster does not look dead when watched from distance.
// Nothing else to do, wander randomly.
await this.Monster.RandomMoveAsync().ConfigureAwait(false);
}

/// <summary>
/// Returns the current target if still valid, otherwise searches for a new one.
/// </summary>
private async ValueTask<IAttackable?> ResolveTargetAsync()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ResolveTargetAsync method and IsCurrentTargetValid methods are introduced to encapsulate target validation logic. This improves readability and maintainability.

{
if (this.CurrentTarget is not null && this.IsCurrentTargetValid())
{
// Double-check the target is still within the observer list (needed for
// players who have moved out of view range server-side).
if (!await this.IsTargetInObserversAsync(this.CurrentTarget).ConfigureAwait(false)
&& this.CurrentTarget is IWorldObserver)
{
return await this.SearchNextTargetAsync().ConfigureAwait(false);
}

return this.CurrentTarget;
}

return await this.SearchNextTargetAsync().ConfigureAwait(false);
}

/// <summary>
/// Returns <c>true</c> if the current target is still a valid attack candidate.
/// </summary>
private bool IsCurrentTargetValid()
{
return this.CurrentTarget is not null
&& this.CurrentTarget.IsAlive
&& this.CurrentTarget is not Player { IsInvisible: true }
&& !this.CurrentTarget.IsTeleporting
&& !this.CurrentTarget.IsAtSafezone()
&& this.CurrentTarget.IsInRange(this.Monster.Position, this.Npc.Definition.ViewRange);
}
}
Loading