Skip to content

Commit 8b16f9a

Browse files
committed
Client Coalescing
1 parent 2237365 commit 8b16f9a

File tree

6 files changed

+227
-13
lines changed

6 files changed

+227
-13
lines changed

Zolian.Server.Base/GameScripts/Skills/ClericSkillTree.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ protected override void OnSuccess(Sprite sprite)
3434
if (sprite is not Damageable damageDealer) return;
3535
SendPortAnimation(damageDealer, _oldPosition);
3636
damageDealer.SendAnimationNearby(76, null, damageDealer.Serial);
37-
damageDealer.SendTargetedClientMethod(PlayerScope.NearbyAislings, c => c.SendSound(Skill.Template.Sound, false));
37+
damageDealer.SendTargetedClientMethod(PlayerScope.NearbyAislings, c => c.SendSoundImmediate(Skill.Template.Sound, false));
3838
}
3939
catch
4040
{

Zolian.Server.Base/GameScripts/Skills/NinjaSkillTree.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ protected override void OnSuccess(Sprite sprite)
3737
if (sprite is not Damageable damageDealer) return;
3838
SendPortAnimation(damageDealer, _oldPosition);
3939
damageDealer.SendAnimationNearby(76, null, damageDealer.Serial);
40-
damageDealer.SendTargetedClientMethod(PlayerScope.NearbyAislings, c => c.SendSound(Skill.Template.Sound, false));
40+
damageDealer.SendTargetedClientMethod(PlayerScope.NearbyAislings, c => c.SendSoundImmediate(Skill.Template.Sound, false));
4141
}
4242
catch
4343
{

Zolian.Server.Base/GameScripts/Spells/GlobalSpellMethods.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,7 @@ public static void AfflictionOnSuccess(Sprite sprite, Sprite target, Spell spell
364364
targetPlayer.Client.SendServerMessage(ServerMessageType.ActiveMessage, $"{aisling.Username} afflicts you with {spell.Template.Name}");
365365

366366
aisling.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
367-
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
367+
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
368368
}
369369
else
370370
{
@@ -385,7 +385,7 @@ public static void AfflictionOnSuccess(Sprite sprite, Sprite target, Spell spell
385385

386386
damageable.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
387387
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendBodyAnimation(sprite.Serial, BodyAnimation.Assail, 30));
388-
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
388+
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
389389
}
390390
}
391391

@@ -403,7 +403,7 @@ public static void PoisonOnSuccess(Sprite sprite, Sprite target, Spell spell, De
403403
targetPlayer.Client.SendServerMessage(ServerMessageType.ActiveMessage, $"{aisling.Username} poisons you with {spell.Template.Name}.");
404404

405405
aisling.SendAnimationNearby(spell.Template.Animation, null, target.Serial);
406-
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
406+
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
407407
}
408408
else
409409
{
@@ -424,7 +424,7 @@ public static void PoisonOnSuccess(Sprite sprite, Sprite target, Spell spell, De
424424

425425
damageable.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
426426
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendBodyAnimation(sprite.Serial, BodyAnimation.Assail, 30));
427-
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
427+
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
428428
}
429429
}
430430

@@ -438,7 +438,7 @@ public static void SpellOnSuccess(Sprite sprite, Sprite target, Spell spell)
438438
if (target.CurrentHp > 0)
439439
{
440440
damageable.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
441-
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
441+
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
442442
}
443443
else
444444
{
@@ -586,7 +586,7 @@ public static void EnhancementOnSuccess(Sprite sprite, Sprite target, Spell spel
586586
if (target.CurrentHp > 0)
587587
{
588588
aisling.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
589-
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
589+
aisling.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
590590
}
591591
else
592592
{
@@ -612,7 +612,7 @@ public static void EnhancementOnSuccess(Sprite sprite, Sprite target, Spell spel
612612

613613
damageable.SendAnimationNearby(spell.Template.TargetAnimation, null, target.Serial);
614614
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendBodyAnimation(sprite.Serial, BodyAnimation.Assail, 30));
615-
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSound(spell.Template.Sound, false));
615+
damageable.SendTargetedClientMethod(PlayerScope.NearbyAislings, client => client.SendSoundImmediate(spell.Template.Sound, false));
616616
}
617617
}
618618

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
using System.Collections.Concurrent;
2+
3+
using Chaos.Networking.Entities.Server;
4+
5+
namespace Darkages.Network.Client;
6+
7+
public sealed class HealthBarCoalescer : IDisposable
8+
{
9+
private readonly Action<HealthBarArgs> _sendImmediate;
10+
private readonly int _windowMs;
11+
private readonly int _maxPerFlush;
12+
13+
// Latest-wins per serial (SourceId)
14+
private readonly ConcurrentDictionary<uint, HealthBarArgs> _pending = new();
15+
16+
private int _flushScheduled;
17+
private CancellationTokenSource? _cts;
18+
19+
/// <sumary>
20+
/// Health bar coalescer to reduce UI client spam
21+
/// </sumary>
22+
public HealthBarCoalescer(Action<HealthBarArgs>? sendImmediate, int windowMs = 50, int maxPerFlush = 32)
23+
{
24+
// Never crash the server over UI packets
25+
sendImmediate ??= static _ => { };
26+
27+
_sendImmediate = sendImmediate;
28+
_windowMs = windowMs <= 0 ? 50 : windowMs;
29+
_maxPerFlush = maxPerFlush <= 0 ? 32 : maxPerFlush;
30+
}
31+
32+
public void Enqueue(HealthBarArgs args)
33+
{
34+
// Newest wins for this serial
35+
_pending[args.SourceId] = args;
36+
37+
// Schedule one flush task per window
38+
if (Interlocked.Exchange(ref _flushScheduled, 1) == 0)
39+
{
40+
_cts ??= new CancellationTokenSource();
41+
_ = FlushAfterDelayAsync(_cts.Token);
42+
}
43+
}
44+
45+
private async Task FlushAfterDelayAsync(CancellationToken ct)
46+
{
47+
try
48+
{
49+
await Task.Delay(_windowMs, ct).ConfigureAwait(false);
50+
51+
var sent = 0;
52+
53+
// Send at most one per serial (dictionary holds only latest per serial)
54+
foreach (var kv in _pending)
55+
{
56+
// Limit per flush window
57+
if (sent >= _maxPerFlush)
58+
break;
59+
60+
if (_pending.TryRemove(kv.Key, out var args))
61+
{
62+
_sendImmediate(args);
63+
sent++;
64+
}
65+
}
66+
67+
// Hard cap protection: drop anything still pending this window
68+
if (!_pending.IsEmpty)
69+
_pending.Clear();
70+
}
71+
catch (OperationCanceledException) { }
72+
finally
73+
{
74+
// Allow another window to schedule
75+
Interlocked.Exchange(ref _flushScheduled, 0);
76+
77+
// If more arrived after finished, schedule again
78+
if (!_pending.IsEmpty && Interlocked.Exchange(ref _flushScheduled, 1) == 0)
79+
{
80+
_ = FlushAfterDelayAsync(ct);
81+
}
82+
}
83+
}
84+
85+
public void Dispose()
86+
{
87+
_cts?.Cancel();
88+
_cts?.Dispose();
89+
_cts = null;
90+
_pending.Clear();
91+
}
92+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using System.Collections.Concurrent;
2+
3+
namespace Darkages.Network.Client.Coalescer;
4+
5+
public sealed class SoundCoalescer : IDisposable
6+
{
7+
private readonly Action<byte, bool> _sendImmediate;
8+
private readonly int _windowMs;
9+
private readonly int _maxUniquePerWindow;
10+
11+
// Key = sound byte (SFX only); Value = sound
12+
private readonly ConcurrentDictionary<int, byte> _pending = new();
13+
14+
private int _flushScheduled;
15+
private CancellationTokenSource? _cts;
16+
17+
public SoundCoalescer(Action<byte, bool> sendImmediate, int windowMs = 50, int maxUniquePerWindow = 32)
18+
{
19+
// Default no-op
20+
sendImmediate ??= (byte _, bool __) => { };
21+
22+
_sendImmediate = sendImmediate;
23+
_windowMs = windowMs <= 0 ? 50 : windowMs;
24+
_maxUniquePerWindow = maxUniquePerWindow is <= 0 or > 256 ? 32 : maxUniquePerWindow;
25+
}
26+
27+
public void Enqueue(byte sound, bool isMusic)
28+
{
29+
// Music is not coalesced
30+
if (isMusic)
31+
{
32+
_sendImmediate(sound, true);
33+
return;
34+
}
35+
36+
// Dedupe within the window
37+
_pending.TryAdd(sound, sound);
38+
39+
// Schedule one flush task per window.
40+
if (Interlocked.Exchange(ref _flushScheduled, 1) == 0)
41+
{
42+
_cts ??= new CancellationTokenSource();
43+
_ = FlushAfterDelayAsync(_cts.Token);
44+
}
45+
}
46+
47+
private async Task FlushAfterDelayAsync(CancellationToken ct)
48+
{
49+
try
50+
{
51+
await Task.Delay(_windowMs, ct).ConfigureAwait(false);
52+
53+
var sent = 0;
54+
Span<byte> sentMap = stackalloc byte[256];
55+
56+
foreach (var kv in _pending)
57+
{
58+
// Max unique per window reached
59+
if (sent >= _maxUniquePerWindow)
60+
break;
61+
62+
// kv.Key is int but should be [0..255] byte
63+
var key = kv.Key;
64+
if ((uint)key > 255u)
65+
continue;
66+
67+
if (sentMap[key] != 0)
68+
continue;
69+
70+
if (_pending.TryRemove(key, out var sfx))
71+
{
72+
sentMap[key] = 1;
73+
_sendImmediate(sfx, false);
74+
sent++;
75+
}
76+
}
77+
78+
// Hard cap protection: drop anything still pending this window
79+
if (!_pending.IsEmpty)
80+
_pending.Clear();
81+
}
82+
catch (OperationCanceledException) { }
83+
finally
84+
{
85+
// Allow another window to schedule
86+
Interlocked.Exchange(ref _flushScheduled, 0);
87+
88+
// If more arrived after finished, schedule again
89+
if (!_pending.IsEmpty && Interlocked.Exchange(ref _flushScheduled, 1) == 0)
90+
{
91+
_ = FlushAfterDelayAsync(ct);
92+
}
93+
}
94+
}
95+
96+
public void Dispose()
97+
{
98+
_cts?.Cancel();
99+
_cts?.Dispose();
100+
_cts = null;
101+
_pending.Clear();
102+
}
103+
}

Zolian.Server.Base/Network/Client/WorldClient.cs

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
using Darkages.Managers;
2929
using Darkages.Meta;
3030
using Darkages.Models;
31+
using Darkages.Network.Client.Coalescer;
3132
using Darkages.Network.Server;
3233
using Darkages.Object;
3334
using Darkages.ScriptingBase;
@@ -59,6 +60,9 @@ namespace Darkages.Network.Client;
5960
public class WorldClient : WorldClientBase, IWorldClient
6061
{
6162
private readonly IWorldServer<WorldClient> _server;
63+
private readonly SoundCoalescer _soundCoalescer;
64+
private readonly HealthBarCoalescer _healthBarCoalescer;
65+
6266
public readonly WorldServerTimer SkillSpellTimer = new(TimeSpan.FromMilliseconds(1000));
6367
public readonly Stopwatch CooldownControl = new();
6468
public readonly Stopwatch SpellControl = new();
@@ -166,6 +170,8 @@ public WorldClient([NotNull] IWorldServer<IWorldClient> server, [NotNull] Socket
166170
[NotNull] ILogger<WorldClient> logger) : base(socket, crypto, packetSerializer, logger)
167171
{
168172
_server = server;
173+
_soundCoalescer = new SoundCoalescer(SendSoundImmediate, 150, 32);
174+
_healthBarCoalescer = new HealthBarCoalescer(SendHealthBarCoalesced, 150, 32);
169175

170176
// Event-Driven Tasks
171177
Task.Factory.StartNew(ProcessExperienceEvents, TaskCreationOptions.LongRunning);
@@ -342,7 +348,8 @@ private void CheckInvisible(Aisling player, Dictionary<string, TimeSpan> elapsed
342348

343349
private void EquipLantern(Dictionary<string, TimeSpan> elapsed)
344350
{
345-
if (elapsed["Lantern"].TotalMilliseconds < 2000) return;
351+
if (elapsed["Lantern"].TotalMilliseconds < 1500) return;
352+
346353
_clientStopwatches["Lantern"].Restart();
347354
if (Aisling.Map == null) return;
348355
if (Aisling.Map.Flags.MapFlagIsSet(MapFlags.Darkness))
@@ -2106,6 +2113,11 @@ public void SendDisplayGroupInvite(ServerGroupSwitch serverGroupSwitch, string f
21062113
Send(args);
21072114
}
21082115

2116+
/// <summary>
2117+
/// 0x13 - Health Bar - Coalesced Send
2118+
/// </summary>
2119+
private void SendHealthBarCoalesced(HealthBarArgs args) => Send(args);
2120+
21092121
/// <summary>
21102122
/// 0x13 - Health Bar
21112123
/// </summary>
@@ -2123,7 +2135,7 @@ public void SendHealthBar(Sprite creature, byte? sound = 0xFF)
21232135
Tail = creature is Aisling ? null : 0x00
21242136
};
21252137

2126-
Send(args);
2138+
_healthBarCoalescer.Enqueue(args);
21272139
}
21282140

21292141
/// <summary>
@@ -3243,11 +3255,11 @@ public void SendServerMessage(ServerMessageType serverMessageType, string messag
32433255
}
32443256

32453257
/// <summary>
3246-
/// 0x19 - Send Sound
3258+
/// 0x19 - Send Sound - Bypasses Coalescer (And is used by Coalesced Send)
32473259
/// </summary>
32483260
/// <param name="sound">Sound Number</param>
32493261
/// <param name="isMusic">Whether the sound is a song</param>
3250-
public void SendSound(byte sound, bool isMusic)
3262+
public void SendSoundImmediate(byte sound, bool isMusic)
32513263
{
32523264
var args = new SoundArgs
32533265
{
@@ -3258,6 +3270,13 @@ public void SendSound(byte sound, bool isMusic)
32583270
Send(args);
32593271
}
32603272

3273+
/// <summary>
3274+
/// 0x19 - Send Sound
3275+
/// </summary>
3276+
/// <param name="sound">Sound Number</param>
3277+
/// <param name="isMusic">Whether the sound is a song</param>
3278+
public void SendSound(byte sound, bool isMusic) => _soundCoalescer.Enqueue(sound, isMusic);
3279+
32613280
/// <summary>
32623281
/// 0x38 - Remove Equipment
32633282
/// </summary>

0 commit comments

Comments
 (0)