diff --git a/src/Core/Core/Event/EventSystem.cs b/src/Core/Core/Event/EventSystem.cs index e8e8e96c..de10e6ec 100644 --- a/src/Core/Core/Event/EventSystem.cs +++ b/src/Core/Core/Event/EventSystem.cs @@ -24,8 +24,16 @@ public static void Update(TickContext ctx) { lock (Lock) { - foreach (var (id, evt) in PendingEvents) + // hackish fix which needs refactoring: events can schedule other events, which would otherwise + // throw System.InvalidOperationException: Collection was modified; enumeration operation may not execute. + var pendingSnapshot = new List>(PendingEvents); + foreach (var (id, evt) in pendingSnapshot) { + if (!PendingEvents.TryGetValue(id, out var current) || !ReferenceEquals(current, evt)) + { + continue; + } + evt.Time -= ctx.Delta; if (evt.Time > TimeSpan.Zero) { diff --git a/src/Core/Core/Event/ThreadPoolScheduler.cs b/src/Core/Core/Event/ThreadPoolScheduler.cs new file mode 100644 index 00000000..6691eb7c --- /dev/null +++ b/src/Core/Core/Event/ThreadPoolScheduler.cs @@ -0,0 +1,11 @@ +using QuantumCore.API.Core.Event; + +namespace QuantumCore.Core.Event; + +public sealed class ThreadPoolScheduler : IJobScheduler +{ + public void Schedule(Func work) + { + Task.Run(work); + } +} diff --git a/src/Core/Extensions/ServiceExtensions.cs b/src/Core/Extensions/ServiceExtensions.cs index 814ca39b..f0ddb811 100644 --- a/src/Core/Extensions/ServiceExtensions.cs +++ b/src/Core/Extensions/ServiceExtensions.cs @@ -2,8 +2,10 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using QuantumCore.API.Core.Event; using QuantumCore.API.Core.Timekeeping; using QuantumCore.API.PluginTypes; +using QuantumCore.Core.Event; using QuantumCore.Networking; using Serilog; using Serilog.Events; @@ -85,6 +87,7 @@ public static IServiceCollection AddCoreServices(this IServiceCollection service }); services.AddSingleton(_ => TimeProvider.System); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddPluginFramework() .AddPluginCatalog(pluginCatalog) diff --git a/src/CorePluginAPI/Core/Event/IJobScheduler.cs b/src/CorePluginAPI/Core/Event/IJobScheduler.cs new file mode 100644 index 00000000..ba952ca9 --- /dev/null +++ b/src/CorePluginAPI/Core/Event/IJobScheduler.cs @@ -0,0 +1,6 @@ +namespace QuantumCore.API.Core.Event; + +public interface IJobScheduler +{ + void Schedule(Func work); +} diff --git a/src/CorePluginAPI/Core/Timekeeping/PlayerTimestampKind.cs b/src/CorePluginAPI/Core/Timekeeping/PlayerTimestampKind.cs new file mode 100644 index 00000000..3fe8f0ac --- /dev/null +++ b/src/CorePluginAPI/Core/Timekeeping/PlayerTimestampKind.cs @@ -0,0 +1,14 @@ +namespace QuantumCore.API.Core.Timekeeping; + +public enum PlayerTimestampKind +{ + DIED, + RESPAWNED, + DAMAGE_TAKEN, + DAMAGE_DEALT, + USED_SKILL, + ATTACK_INITIATED, + RESTORED_HEALTH, + RESTORED_MANA, + AUTOSAVED +} diff --git a/src/CorePluginAPI/Core/Timekeeping/TimestampRegistry.cs b/src/CorePluginAPI/Core/Timekeeping/TimestampRegistry.cs new file mode 100644 index 00000000..e1ee57b6 --- /dev/null +++ b/src/CorePluginAPI/Core/Timekeeping/TimestampRegistry.cs @@ -0,0 +1,97 @@ +using EnumsNET; + +namespace QuantumCore.API.Core.Timekeeping; + +/// +/// Simple enum-indexed storage for optional server timestamps. +/// Made thread-safe using . +/// +public sealed class TimestampRegistry where TKind : struct, Enum +{ + private static readonly int MaxEnumValue = Enums.GetValues().Select(Enums.ToInt32).Max(); + + private readonly Lock _lock = new(); + private readonly ServerTimestamp?[] _slots = new ServerTimestamp?[MaxEnumValue + 1]; + + public ServerTimestamp? this[TKind kind] + { + get => Get(kind); + set + { + if (value.HasValue) + { + Mark(kind, value.Value); + } + else + { + Clear(kind); + } + } + } + + public void Mark(TKind kind, ServerTimestamp timestamp) + { + lock (_lock) + { + _slots[Enums.ToInt32(kind)] = timestamp; + } + } + + public void Clear(TKind kind) + { + lock (_lock) + { + _slots[Enums.ToInt32(kind)] = null; + } + } + + public bool UpdateIfElapsed(TickContext ctx, TKind kind, TimeSpan minimumElapsed) + { + // need lock for transactional update (check-then-act) + lock (_lock) + { + var ts = _slots[Enums.ToInt32(kind)]; + if (ts.HasValue) + { + var tickTimestampOutsideGracePeriod = ctx.ElapsedSince(ts) > minimumElapsed; + if (tickTimestampOutsideGracePeriod) + { + _slots[Enums.ToInt32(kind)] = ctx.Timestamp; + return true; + } + } + else + { + _slots[Enums.ToInt32(kind)] = ctx.Timestamp; + } + + return false; + } + } + + public ServerTimestamp? Get(TKind kind) + { + lock (_lock) + { + return _slots[Enums.ToInt32(kind)]; + } + } + + public ServerTimestamp? LatestOf(params TKind[] kinds) + { + lock (_lock) + { + ServerTimestamp? latest = null; + foreach (var kind in kinds) + { + var ts = _slots[Enums.ToInt32(kind)]; + if (ts > latest) + { + latest = ts; + } + } + + return latest; + } + } +} diff --git a/src/CorePluginAPI/CorePluginAPI.csproj b/src/CorePluginAPI/CorePluginAPI.csproj index f705baad..f5b57e5a 100644 --- a/src/CorePluginAPI/CorePluginAPI.csproj +++ b/src/CorePluginAPI/CorePluginAPI.csproj @@ -1,16 +1,17 @@ - - QuantumCore.API - + + QuantumCore.API + - - - - + + + + + - - - + + + diff --git a/src/CorePluginAPI/Game/World/IEntity.cs b/src/CorePluginAPI/Game/World/IEntity.cs index 755aea1d..f2a86055 100644 --- a/src/CorePluginAPI/Game/World/IEntity.cs +++ b/src/CorePluginAPI/Game/World/IEntity.cs @@ -63,4 +63,5 @@ public interface IEntity public void Move(int x, int y); public void Stop(); public void Die(); + public bool TryKnockout(); } diff --git a/src/CorePluginAPI/Game/World/IPlayerEntity.cs b/src/CorePluginAPI/Game/World/IPlayerEntity.cs index 02612c4e..8f66232b 100644 --- a/src/CorePluginAPI/Game/World/IPlayerEntity.cs +++ b/src/CorePluginAPI/Game/World/IPlayerEntity.cs @@ -1,4 +1,5 @@ using QuantumCore.API.Core.Models; +using QuantumCore.API.Core.Timekeeping; using QuantumCore.API.Game.Skills; using QuantumCore.API.Game.Types.Entities; using QuantumCore.API.Game.Types.Items; @@ -24,6 +25,7 @@ public interface IPlayerEntity : IEntity Dictionary Quests { get; } EAntiFlags AntiFlagClass { get; } EAntiFlags AntiFlagGender { get; } + TimestampRegistry Timeline { get; } Task Load(); Task ReloadPermissions(); diff --git a/src/CorePluginAPI/IGroundItem.cs b/src/CorePluginAPI/IGroundItem.cs index 26247fa5..d2141076 100644 --- a/src/CorePluginAPI/IGroundItem.cs +++ b/src/CorePluginAPI/IGroundItem.cs @@ -8,4 +8,5 @@ public interface IGroundItem : IEntity ItemInstance Item { get; } uint Amount { get; } string? OwnerName { get; } + bool ReleaseOwnership(); } diff --git a/src/CorePluginAPI/Systems/Events/IEventSlot.cs b/src/CorePluginAPI/Systems/Events/IEventSlot.cs new file mode 100644 index 00000000..30f8d47a --- /dev/null +++ b/src/CorePluginAPI/Systems/Events/IEventSlot.cs @@ -0,0 +1,7 @@ +namespace QuantumCore.API.Systems.Events; + +// Strongly typed wrapper over the raw event ID; null id signifies unscheduled +public interface IEventSlot +{ + long? EventId { get; set; } +} diff --git a/src/CorePluginAPI/Systems/Events/ISchedulable.cs b/src/CorePluginAPI/Systems/Events/ISchedulable.cs new file mode 100644 index 00000000..15b12212 --- /dev/null +++ b/src/CorePluginAPI/Systems/Events/ISchedulable.cs @@ -0,0 +1,15 @@ +using QuantumCore.API.Game.World; + +namespace QuantumCore.API.Systems.Events; + +public interface ISchedulable : IEventSlot where TEntity : IEntity +{ + long EnqueueEvent(TEntity entity); + bool Cancel(); +} + +public interface ISchedulable : IEventSlot where TEntity : IEntity +{ + long EnqueueEvent(TEntity entity, TArgs args); + bool Cancel(); +} diff --git a/src/Libraries/Game.Server/Commands/LogoutCommand.cs b/src/Libraries/Game.Server/Commands/LogoutCommand.cs index fc6e5d01..0b9c79af 100644 --- a/src/Libraries/Game.Server/Commands/LogoutCommand.cs +++ b/src/Libraries/Game.Server/Commands/LogoutCommand.cs @@ -1,6 +1,8 @@ using QuantumCore.API.Game; using QuantumCore.API.Game.World; using QuantumCore.Caching; +using QuantumCore.Game.Systems.Events; +using QuantumCore.Game.World.Entities; namespace QuantumCore.Game.Commands; @@ -18,12 +20,31 @@ public LogoutCommand(IWorld world, ICacheManager cacheManager) _cacheManager = cacheManager; } - public async Task ExecuteAsync(CommandContext context) + public Task ExecuteAsync(CommandContext context) { - context.Player.SendChatInfo("Logging out. Please wait."); - await context.Player.CalculatePlayedTimeAsync(); - await _world.DespawnPlayerAsync(context.Player); - await _cacheManager.Del("account:token:" + context.Player.Player.AccountId); - context.Player.Disconnect(); + if (context.Player is not PlayerEntity { Events: var events } player) + { + throw new NotImplementedException(); + } + + // toggle mechanism + if (events.Cancel(events.SafeLogoutCountdown)) + { + player.SendChatInfo("Your logout has been cancelled."); + return Task.CompletedTask; + } + + player.SendChatInfo("Logging out. Please wait."); + + events.Schedule(events.SafeLogoutCountdown, new SafeLogoutCountdownEvent.Args( + "{0} seconds until logout.", + async () => + { + await player.CalculatePlayedTimeAsync(); + await _world.DespawnPlayerAsync(player); + await _cacheManager.Del("account:token:" + player.Player.AccountId); + player.Disconnect(); + })); + return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Libraries/Game.Server/Commands/PhaseSelectCommand.cs b/src/Libraries/Game.Server/Commands/PhaseSelectCommand.cs index 3364da5f..8de2f986 100644 --- a/src/Libraries/Game.Server/Commands/PhaseSelectCommand.cs +++ b/src/Libraries/Game.Server/Commands/PhaseSelectCommand.cs @@ -6,6 +6,8 @@ using QuantumCore.Extensions; using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; +using QuantumCore.Game.Systems.Events; +using QuantumCore.Game.World.Entities; namespace QuantumCore.Game.Commands; @@ -14,45 +16,61 @@ namespace QuantumCore.Game.Commands; public class PhaseSelectCommand : ICommandHandler { private readonly IWorld _world; - private readonly IServiceProvider _serviceProvider; + private readonly IServiceScopeFactory _scopeFactory; - public PhaseSelectCommand(IWorld world, IServiceProvider serviceProvider) + public PhaseSelectCommand(IWorld world, IServiceScopeFactory scopeFactory) { _world = world; - _serviceProvider = serviceProvider; + _scopeFactory = scopeFactory; } - public async Task ExecuteAsync(CommandContext context) + public Task ExecuteAsync(CommandContext context) { - if (context.Player.Connection.AccountId is null) return; + if (context.Player is not PlayerEntity { Events: var events } player) + { + throw new NotImplementedException(); + } + + // toggle mechanism + if (events.Cancel(events.SafeLogoutCountdown)) + { + player.SendChatInfo("Your logout has been cancelled."); + return Task.CompletedTask; + } - context.Player.SendChatInfo("Going back to character selection. Please wait."); + if (player.Connection.AccountId is null) return Task.CompletedTask; - // todo implement wait + player.SendChatInfo("Going back to character selection. Please wait."); - await context.Player.CalculatePlayedTimeAsync(); + events.Schedule(events.SafeLogoutCountdown, new SafeLogoutCountdownEvent.Args( + "{0} seconds until character selection.", + async () => + { + await player.CalculatePlayedTimeAsync(); + await _world.DespawnPlayerAsync(player); + player.Connection.SetPhase(EPhase.SELECT); - await _world.DespawnPlayerAsync(context.Player); - context.Player.Connection.SetPhase(EPhase.SELECT); + var characters = new Characters(); + await using var scope = _scopeFactory.CreateAsyncScope(); + var playerManager = scope.ServiceProvider.GetRequiredService(); + var guildManager = scope.ServiceProvider.GetRequiredService(); + var charactersFromCacheOrDb = await playerManager.GetPlayers(player.Connection.AccountId.Value); + foreach (var playerData in charactersFromCacheOrDb) + { + var host = _world.GetMapHost(playerData.PositionX, playerData.PositionY); + var guild = await guildManager.GetGuildForPlayerAsync(playerData.Id); - var characters = new Characters(); - await using var scope = _serviceProvider.CreateAsyncScope(); - var playerManager = scope.ServiceProvider.GetRequiredService(); - var guildManager = scope.ServiceProvider.GetRequiredService(); - var charactersFromCacheOrDb = await playerManager.GetPlayers(context.Player.Connection.AccountId.Value); - foreach (var player in charactersFromCacheOrDb) - { - var host = _world.GetMapHost(player.PositionX, player.PositionY); - var guild = await guildManager.GetGuildForPlayerAsync(player.Id); - - var slot = (int)player.Slot; - characters.CharacterList[slot] = player.ToCharacter(); - characters.CharacterList[slot].Ip = BitConverter.ToInt32(host._ip.GetAddressBytes()); - characters.CharacterList[slot].Port = host._port; - characters.GuildIds[slot] = guild?.Id ?? 0; - characters.GuildNames[slot] = guild?.Name ?? ""; - } + var slot = (int)playerData.Slot; + characters.CharacterList[slot] = playerData.ToCharacter(); + characters.CharacterList[slot].Ip = BitConverter.ToInt32(host._ip.GetAddressBytes()); + characters.CharacterList[slot].Port = host._port; + characters.GuildIds[slot] = guild?.Id ?? 0; + characters.GuildNames[slot] = guild?.Name ?? ""; + } + + player.Connection.Send(characters); + })); - context.Player.Connection.Send(characters); + return Task.CompletedTask; } } diff --git a/src/Libraries/Game.Server/Commands/PurgeCommand.cs b/src/Libraries/Game.Server/Commands/PurgeCommand.cs index 01ba8dd6..169c1e33 100644 --- a/src/Libraries/Game.Server/Commands/PurgeCommand.cs +++ b/src/Libraries/Game.Server/Commands/PurgeCommand.cs @@ -13,7 +13,9 @@ public Task ExecuteAsync(CommandContext context) const int MAX_DISTANCE = 10000; var p = context.Player; var all = context.Arguments.Argument == PurgeCommandOption.ALL; - foreach (var e in context.Player.Map!.Entities) + // hack to avoid System.InvalidOperationException: Collection was modified; enumeration operation may not execute. + var mapEntitiesSnapshot = context.Player.Map!.Entities.AsEnumerable().ToArray(); + foreach (var e in mapEntitiesSnapshot) { if (e is IPlayerEntity) continue; var distance = MathUtils.Distance(e.PositionX, e.PositionY, p.PositionX, p.PositionY); diff --git a/src/Libraries/Game.Server/Commands/QuitCommand.cs b/src/Libraries/Game.Server/Commands/QuitCommand.cs index cc2c2385..5a1948cd 100644 --- a/src/Libraries/Game.Server/Commands/QuitCommand.cs +++ b/src/Libraries/Game.Server/Commands/QuitCommand.cs @@ -1,5 +1,7 @@ using QuantumCore.API.Game; using QuantumCore.API.Game.World; +using QuantumCore.Game.Systems.Events; +using QuantumCore.Game.World.Entities; namespace QuantumCore.Game.Commands; @@ -14,12 +16,32 @@ public QuitCommand(IWorld world) _world = world; } - public async Task ExecuteAsync(CommandContext context) + public Task ExecuteAsync(CommandContext context) { - context.Player.SendChatInfo("End the game. Please wait."); - context.Player.SendChatCommand("quit"); - await context.Player.CalculatePlayedTimeAsync(); - await _world.DespawnPlayerAsync(context.Player); - context.Player.Disconnect(); + if (context.Player is not PlayerEntity { Events: var events } player) + { + throw new NotImplementedException(); + } + + // toggle mechanism + if (events.Cancel(events.SafeLogoutCountdown)) + { + player.SendChatInfo("Your logout has been cancelled."); + return Task.CompletedTask; + } + + player.SendChatInfo("End the game. Please wait."); + + events.Schedule(events.SafeLogoutCountdown, new SafeLogoutCountdownEvent.Args( + "{0} seconds until quit.", + async () => + { + player.SendChatCommand("quit"); + await player.CalculatePlayedTimeAsync(); + await _world.DespawnPlayerAsync(player); + player.Disconnect(); + })); + + return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Libraries/Game.Server/Commands/RestartHereCommand.cs b/src/Libraries/Game.Server/Commands/RestartHereCommand.cs index 30c43198..6279ccf0 100644 --- a/src/Libraries/Game.Server/Commands/RestartHereCommand.cs +++ b/src/Libraries/Game.Server/Commands/RestartHereCommand.cs @@ -1,4 +1,7 @@ -using QuantumCore.API.Game; +using QuantumCore.API.Core.Timekeeping; +using QuantumCore.API.Game; +using QuantumCore.API.Game.World; +using QuantumCore.Game.Constants; namespace QuantumCore.Game.Commands; @@ -8,7 +11,41 @@ public class RestartHereCommand : ICommandHandler { public Task ExecuteAsync(CommandContext context) { - context.Player.Respawn(false); + context.Player.RestartWithCooldown(false); return Task.CompletedTask; } -} \ No newline at end of file +} + +internal static class PlayerRestartCommandExtensions +{ + internal static void RestartWithCooldown(this IPlayerEntity player, bool inTown) + { + if (!player.Dead) + { + // cannot restart if alive + return; + } + + if (player.Timeline[PlayerTimestampKind.DIED] is not { } deathTimestamp) + { + // timestamp should be set if dead + return; + } + + var clock = player.Connection.Server.Clock; + var validRestartTimestamp = clock.Advance(deathTimestamp, inTown + ? SchedulingConstants.RestartTownMinWait + : SchedulingConstants.RestartHereMinWait); + + if (clock.Now < validRestartTimestamp) + { + var remaining = clock.ElapsedBetween(clock.Now, validRestartTimestamp); + var remainingSeconds = (int)Math.Ceiling(remaining.TotalSeconds); + player.SendChatInfo($"Cannot restart, please wait {remainingSeconds} seconds."); + + return; + } + + player.Respawn(inTown); + } +} diff --git a/src/Libraries/Game.Server/Commands/RestartTownCommand.cs b/src/Libraries/Game.Server/Commands/RestartTownCommand.cs index 313d5638..cb2cd33a 100644 --- a/src/Libraries/Game.Server/Commands/RestartTownCommand.cs +++ b/src/Libraries/Game.Server/Commands/RestartTownCommand.cs @@ -8,7 +8,7 @@ public class RestartTownCommand : ICommandHandler { public Task ExecuteAsync(CommandContext context) { - context.Player.Respawn(true); + context.Player.RestartWithCooldown(true); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Libraries/Game.Server/Constants/SchedulingConstants.cs b/src/Libraries/Game.Server/Constants/SchedulingConstants.cs new file mode 100644 index 00000000..93dfb56a --- /dev/null +++ b/src/Libraries/Game.Server/Constants/SchedulingConstants.cs @@ -0,0 +1,22 @@ +namespace QuantumCore.Game.Constants; + +public static class SchedulingConstants +{ + public const int LOGOUT_WAIT_SECONDS = 3; + public const int LOGOUT_COMBAT_WAIT_SECONDS = 10; + public const int LOGOUT_COMBAT_GRACE_PERIOD_SECONDS = 10; + + public static readonly TimeSpan GroundItemOwnershipLock = TimeSpan.FromSeconds(30); + public static readonly TimeSpan GroundItemLifetime = TimeSpan.FromSeconds(300); + + public static readonly TimeSpan PlayerAutoRespawnDelay = TimeSpan.FromSeconds(180); + public static readonly TimeSpan RestartHereMinWait = TimeSpan.FromSeconds(10); + public static readonly TimeSpan RestartTownMinWait = TimeSpan.FromSeconds(7); + + public static readonly TimeSpan KnockoutToDeathDelay = TimeSpan.FromSeconds(3); + public static readonly TimeSpan MonsterDespawnAfterDeath = TimeSpan.FromSeconds(5); + + public static readonly TimeSpan PlayerAutosaveInterval = TimeSpan.FromSeconds(30); + public static readonly TimeSpan PlayerManaRegenInterval = TimeSpan.FromSeconds(3); + public static readonly TimeSpan PlayerHealthRegenInterval = TimeSpan.FromSeconds(3); +} diff --git a/src/Libraries/Game.Server/Extensions/PaketExtensions.cs b/src/Libraries/Game.Server/Extensions/PaketExtensions.cs index 63a07149..52fbbe5e 100644 --- a/src/Libraries/Game.Server/Extensions/PaketExtensions.cs +++ b/src/Libraries/Game.Server/Extensions/PaketExtensions.cs @@ -1,6 +1,8 @@ using QuantumCore.API.Core.Models; using QuantumCore.API.Game.Types.Players; +using QuantumCore.API.Game.World; using QuantumCore.Game.Packets; +using QuantumCore.Networking; namespace QuantumCore.Game.Extensions; @@ -27,4 +29,20 @@ public static Character ToCharacter(this PlayerData player) SkillGroup = player.SkillGroup }; } + + public static void BroadcastNearby(this IEntity entity, T packet, bool includeSelf = true) where T : IPacketSerializable + { + if (includeSelf && entity is IPlayerEntity player) + { + player.Connection.Send(packet); + } + + // take a snapshot to avoid enumeration failure if the nearby list is being mutated while we send + var nearbySnapshot = entity.NearbyEntities.AsEnumerable().ToArray(); + + foreach (var nearbyPlayer in nearbySnapshot.OfType()) + { + nearbyPlayer.Connection.Send(packet); + } + } } diff --git a/src/Libraries/Game.Server/PacketHandlers/Game/CharacterMoveHandler.cs b/src/Libraries/Game.Server/PacketHandlers/Game/CharacterMoveHandler.cs index 151c5d73..d593096b 100644 --- a/src/Libraries/Game.Server/PacketHandlers/Game/CharacterMoveHandler.cs +++ b/src/Libraries/Game.Server/PacketHandlers/Game/CharacterMoveHandler.cs @@ -4,8 +4,8 @@ using QuantumCore.API.Game.Types.Players; using QuantumCore.API.Game.Types.Skills; using QuantumCore.API.PluginTypes; +using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; -using QuantumCore.Game.World.Entities; namespace QuantumCore.Game.PacketHandlers.Game; @@ -84,14 +84,8 @@ public Task ExecuteAsync(GamePacketContext ctx, CancellationToken : 0 }; - foreach (var entity in ctx.Connection.Player.NearbyEntities) - { - if (entity is PlayerEntity player) - { - player.Connection.Send(movement); - } - } - + ctx.Connection.Player.BroadcastNearby(movement, includeSelf: false); + return Task.CompletedTask; } } diff --git a/src/Libraries/Game.Server/PacketHandlers/Game/SyncPositionsHandler.cs b/src/Libraries/Game.Server/PacketHandlers/Game/SyncPositionsHandler.cs new file mode 100644 index 00000000..13c0561e --- /dev/null +++ b/src/Libraries/Game.Server/PacketHandlers/Game/SyncPositionsHandler.cs @@ -0,0 +1,43 @@ +using QuantumCore.API; +using QuantumCore.API.PluginTypes; +using QuantumCore.Game.Extensions; +using QuantumCore.Game.Packets; +using QuantumCore.Game.World; +using static QuantumCore.API.Game.Types.Entities.EEntityType; + +namespace QuantumCore.Game.PacketHandlers.Game; + +public class SyncPositionsHandler : IGamePacketHandler +{ + public Task ExecuteAsync(GamePacketContext ctx, CancellationToken token = default) + { + if (ctx.Connection.Player is not { Map: Map localMap } player) + { + return Task.CompletedTask; + } + + var positions = new List(ctx.Packet.Positions.Length); + foreach (var position in ctx.Packet.Positions) + { + var entity = localMap.GetEntity(position.Vid); + if (entity is null || entity.Type is NPC or WARP or GOTO) + { + continue; + } + + // TODO: should we sync on the server as well? or only forward to neighboring players? + // i.e. entity.Move(position.X, position.Y); + + positions.Add(position); + } + + if (positions.Count == 0) + { + return Task.CompletedTask; + } + + player.BroadcastNearby(new SyncPositionsOut { Positions = positions.ToArray() }, includeSelf: false); + + return Task.CompletedTask; + } +} diff --git a/src/Libraries/Game.Server/PacketHandlers/Game/UseSkillHandler.cs b/src/Libraries/Game.Server/PacketHandlers/Game/UseSkillHandler.cs index 5e93603a..b4006ccd 100644 --- a/src/Libraries/Game.Server/PacketHandlers/Game/UseSkillHandler.cs +++ b/src/Libraries/Game.Server/PacketHandlers/Game/UseSkillHandler.cs @@ -1,5 +1,6 @@ using Microsoft.Extensions.Logging; using QuantumCore.API; +using QuantumCore.API.Core.Timekeeping; using QuantumCore.API.PluginTypes; using QuantumCore.Game.Packets; @@ -16,8 +17,13 @@ public UseSkillHandler(ILogger logger) public Task ExecuteAsync(GamePacketContext ctx, CancellationToken token = default) { + if (ctx.Connection.Player is { Timeline: var timeline }) + { + timeline[PlayerTimestampKind.USED_SKILL] = ctx.Connection.Server.Clock.Now; + } + _logger.LogWarning("SkillId: {SkillId} on TargetVid: {TargetVid} not implemented", ctx.Packet.SkillId, ctx.Packet.TargetVid); return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/src/Libraries/Game.Server/Packets/KnockoutCharacter.cs b/src/Libraries/Game.Server/Packets/KnockoutCharacter.cs new file mode 100644 index 00000000..ef62d90e --- /dev/null +++ b/src/Libraries/Game.Server/Packets/KnockoutCharacter.cs @@ -0,0 +1,10 @@ +using QuantumCore.Networking; + +namespace QuantumCore.Game.Packets; + +[Packet(0x0d, EDirection.OUTGOING)] +[PacketGenerator] +public partial class KnockoutCharacter +{ + [Field(0)] public uint Vid { get; set; } +} diff --git a/src/Libraries/Game.Server/Packets/SyncPositionElement.cs b/src/Libraries/Game.Server/Packets/SyncPositionElement.cs new file mode 100644 index 00000000..6a74fcf2 --- /dev/null +++ b/src/Libraries/Game.Server/Packets/SyncPositionElement.cs @@ -0,0 +1,10 @@ +using QuantumCore.Networking; + +namespace QuantumCore.Game.Packets; + +public struct SyncPositionElement +{ + [Field(0)] public uint Vid { get; set; } + [Field(1)] public int X { get; set; } + [Field(2)] public int Y { get; set; } +} diff --git a/src/Libraries/Game.Server/Packets/SyncPositions.cs b/src/Libraries/Game.Server/Packets/SyncPositions.cs new file mode 100644 index 00000000..f4d53469 --- /dev/null +++ b/src/Libraries/Game.Server/Packets/SyncPositions.cs @@ -0,0 +1,114 @@ +using System.Buffers; +using QuantumCore.Networking; + +namespace QuantumCore.Game.Packets; + +[Packet(0x08, EDirection.INCOMING, Sequence = true)] +// TODO: enhance generator to support variable length arrays by the TotalSize field +public class SyncPositions : IPacketSerializable +{ + private const int ELEMENT_SIZE = 12; // SyncPositionElement = uint + int + int + private const int HEADER_SIZE = 1; + private const int TOTAL_SIZE_FIELD_SIZE = 2; + + public ushort TotalSize { get; private init; } + public SyncPositionElement[] Positions { get; private init; } = []; + + public ushort GetSize() + { + return (ushort)(TOTAL_SIZE_FIELD_SIZE + Positions.Length * ELEMENT_SIZE); + } + + public void Serialize(byte[] bytes, in int offset = 0) + { + var totalSize = TotalSize != 0 + ? TotalSize + : (ushort)(HEADER_SIZE + TOTAL_SIZE_FIELD_SIZE + Positions.Length * ELEMENT_SIZE); + BitConverter.GetBytes(totalSize).CopyTo(bytes, offset); + + for (var i = 0; i < Positions.Length; i++) + { + var elementOffset = offset + TOTAL_SIZE_FIELD_SIZE + i * ELEMENT_SIZE; + var element = Positions[i]; + BitConverter.GetBytes(element.Vid).CopyTo(bytes, elementOffset); + BitConverter.GetBytes(element.X).CopyTo(bytes, elementOffset + 4); + BitConverter.GetBytes(element.Y).CopyTo(bytes, elementOffset + 8); + } + } + + public static SyncPositions Deserialize(ReadOnlySpan bytes, in int offset = 0) + { + var totalSize = BitConverter.ToUInt16(bytes.Slice(offset, TOTAL_SIZE_FIELD_SIZE)); + var payloadSize = Math.Max(0, totalSize - HEADER_SIZE - TOTAL_SIZE_FIELD_SIZE); + var count = payloadSize / ELEMENT_SIZE; + + var positions = new SyncPositionElement[count]; + for (var i = 0; i < count; i++) + { + var elementOffset = offset + TOTAL_SIZE_FIELD_SIZE + i * ELEMENT_SIZE; + positions[i] = new SyncPositionElement + { + Vid = BitConverter.ToUInt32(bytes.Slice(elementOffset, 4)), + X = BitConverter.ToInt32(bytes.Slice(elementOffset + 4, 4)), + Y = BitConverter.ToInt32(bytes.Slice(elementOffset + 8, 4)) + }; + } + + return new SyncPositions + { + TotalSize = totalSize, + Positions = positions + }; + } + + public static T Deserialize(ReadOnlySpan bytes, in int offset = 0) + where T : IPacketSerializable + { + return (T)(object)Deserialize(bytes, offset); + } + + public static async ValueTask DeserializeFromStreamAsync(Stream stream) + { + var buffer = ArrayPool.Shared.Rent(NetworkingConstants.BufferSize); + try + { + var totalSize = await stream.ReadValueFromStreamAsync(buffer); + var payloadSize = Math.Max(0, totalSize - HEADER_SIZE - TOTAL_SIZE_FIELD_SIZE); + var count = payloadSize / ELEMENT_SIZE; + + var positions = new SyncPositionElement[count]; + for (var i = 0; i < count; i++) + { + positions[i] = new SyncPositionElement + { + Vid = await stream.ReadValueFromStreamAsync(buffer), + X = await stream.ReadValueFromStreamAsync(buffer), + Y = await stream.ReadValueFromStreamAsync(buffer) + }; + } + + var remaining = payloadSize - count * ELEMENT_SIZE; + while (remaining > 0) + { + var chunk = Math.Min(remaining, buffer.Length); + await stream.ReadExactlyAsync(buffer.AsMemory(0, chunk)); + remaining -= chunk; + } + + return new SyncPositions + { + TotalSize = totalSize, + Positions = positions + }; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + public static byte Header => 0x08; + public static byte? SubHeader => null; + public static bool HasStaticSize => false; + public static bool HasSequence => true; +} diff --git a/src/Libraries/Game.Server/Packets/SyncPositionsOut.cs b/src/Libraries/Game.Server/Packets/SyncPositionsOut.cs new file mode 100644 index 00000000..6985907f --- /dev/null +++ b/src/Libraries/Game.Server/Packets/SyncPositionsOut.cs @@ -0,0 +1,13 @@ +using QuantumCore.Networking; + +namespace QuantumCore.Game.Packets; + +[Packet(0x05, EDirection.OUTGOING)] +[PacketGenerator] +public partial class SyncPositionsOut +{ + // Reference Positions so generator links this as the size field, but use GetSize() for actual value. + [Field(0)] public ushort TotalSize => (ushort)(GetSize() + Positions.Length * 0); + + [Field(1)] public SyncPositionElement[] Positions { get; init; } = []; +} diff --git a/src/Libraries/Game.Server/Systems/Events/EntityEventRegistry.cs b/src/Libraries/Game.Server/Systems/Events/EntityEventRegistry.cs new file mode 100644 index 00000000..31ae5f62 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/EntityEventRegistry.cs @@ -0,0 +1,36 @@ +using QuantumCore.API.Game.World; +using QuantumCore.API.Systems.Events; + +namespace QuantumCore.Game.Systems.Events; + +public class EntityEventRegistry : EntityEventRegistryBase where TEntity : IEntity +{ + private readonly TEntity _entity; + + protected EntityEventRegistry(TEntity entity) : base(entity) + { + _entity = entity; + } + + public long Schedule(ISchedulable schedulable) + { + Cancel(schedulable); + + var eventId = schedulable.EnqueueEvent(_entity); + schedulable.EventId = eventId; + return eventId; + } + + public long Schedule(ISchedulable schedulable, TArgs args) + { + Cancel(schedulable); + + var eventId = schedulable.EnqueueEvent(_entity, args); + schedulable.EventId = eventId; + return eventId; + } + + public bool Cancel(ISchedulable schedulable) => schedulable.Cancel(); + + public bool Cancel(ISchedulable schedulable) => schedulable.Cancel(); +} diff --git a/src/Libraries/Game.Server/Systems/Events/EntityEventRegistryBase.cs b/src/Libraries/Game.Server/Systems/Events/EntityEventRegistryBase.cs new file mode 100644 index 00000000..e4a764e3 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/EntityEventRegistryBase.cs @@ -0,0 +1,43 @@ +using QuantumCore.API.Game.World; +using QuantumCore.API.Systems.Events; +using QuantumCore.Game.Constants; + +namespace QuantumCore.Game.Systems.Events; + +/// +/// This class (and its inheritors) is created to help with readability +/// by declaring all specific event types as properties or "event slots", therefore +/// avoiding bloat in the main entity classes, centralizing scheduling configuration, +/// and discouraging haphazardly scheduling random types of events throughout the code. +/// +public abstract class EntityEventRegistryBase : IDisposable +{ + private readonly IEntity _entity; + + protected EntityEventRegistryBase(IEntity entity) + { + _entity = entity; + } + + public OneShotEvent KnockoutDeath { get; } = new(SchedulingConstants.KnockoutToDeathDelay, + target => target.Die()); + + public static bool IsScheduled(IEventSlot eventSlot) => eventSlot.EventId.HasValue; + + public long Schedule(ISchedulable schedulable) + { + schedulable.Cancel(); + + var eventId = schedulable.EnqueueEvent(_entity); + schedulable.EventId = eventId; + return eventId; + } + + public static bool Cancel(ISchedulable schedulable) => schedulable.Cancel(); + + public virtual void Dispose() + { + KnockoutDeath.Cancel(); + } + +} diff --git a/src/Libraries/Game.Server/Systems/Events/GroundItemEventRegistry.cs b/src/Libraries/Game.Server/Systems/Events/GroundItemEventRegistry.cs new file mode 100644 index 00000000..b51551b7 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/GroundItemEventRegistry.cs @@ -0,0 +1,20 @@ +using QuantumCore.API; +using QuantumCore.Game.Constants; + +namespace QuantumCore.Game.Systems.Events; + +public sealed class GroundItemEventRegistry(IGroundItem item) : EntityEventRegistry(item) +{ + public OneShotEvent OwnershipExpiry { get; } = new(SchedulingConstants.GroundItemOwnershipLock, + groundItem => groundItem.ReleaseOwnership()); + + public OneShotEvent LifetimeExpiry { get; } = new(SchedulingConstants.GroundItemLifetime, + groundItem => groundItem.Map?.DespawnEntity(groundItem)); + + public override void Dispose() + { + Cancel(OwnershipExpiry); + Cancel(LifetimeExpiry); + base.Dispose(); + } +} diff --git a/src/Libraries/Game.Server/Systems/Events/MonsterEventRegistry.cs b/src/Libraries/Game.Server/Systems/Events/MonsterEventRegistry.cs new file mode 100644 index 00000000..11cfbadd --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/MonsterEventRegistry.cs @@ -0,0 +1,16 @@ +using QuantumCore.API.Game.World; +using QuantumCore.Game.Constants; + +namespace QuantumCore.Game.Systems.Events; + +public sealed class MonsterEventRegistry(IEntity monster) : EntityEventRegistry(monster) +{ + public OneShotEvent DespawnAfterDeath { get; } = new(SchedulingConstants.MonsterDespawnAfterDeath, + m => m.Map?.DespawnEntity(m)); + + public override void Dispose() + { + Cancel(DespawnAfterDeath); + base.Dispose(); + } +} diff --git a/src/Libraries/Game.Server/Systems/Events/OneShotEvent.cs b/src/Libraries/Game.Server/Systems/Events/OneShotEvent.cs new file mode 100644 index 00000000..0d4085ec --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/OneShotEvent.cs @@ -0,0 +1,33 @@ +using QuantumCore.API.Game.World; +using QuantumCore.API.Systems.Events; +using QuantumCore.Core.Event; + +namespace QuantumCore.Game.Systems.Events; + +public sealed class OneShotEvent(TimeSpan delay, Action callback) : ISchedulable + where TEntity : IEntity +{ + public long? EventId { get; set; } + + public long EnqueueEvent(TEntity entity) + { + return EventSystem.EnqueueEvent(() => + { + EventId = null; + callback(entity); + return TimeSpan.Zero; + }, delay); + } + + public bool Cancel() + { + if (!EventId.HasValue) + { + return false; + } + + EventSystem.CancelEvent(EventId.Value); + EventId = null; + return true; + } +} diff --git a/src/Libraries/Game.Server/Systems/Events/PlayerEventRegistry.cs b/src/Libraries/Game.Server/Systems/Events/PlayerEventRegistry.cs new file mode 100644 index 00000000..52dcfe39 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/PlayerEventRegistry.cs @@ -0,0 +1,24 @@ +using QuantumCore.API.Core.Event; +using QuantumCore.API.Game.World; +using static QuantumCore.Game.Constants.SchedulingConstants; + +namespace QuantumCore.Game.Systems.Events; + +public sealed class PlayerEventRegistry(IPlayerEntity player, IJobScheduler jobScheduler) + : EntityEventRegistry(player) +{ + public SafeLogoutCountdownEvent SafeLogoutCountdown { get; } = + new(LOGOUT_WAIT_SECONDS, LOGOUT_COMBAT_WAIT_SECONDS, LOGOUT_COMBAT_GRACE_PERIOD_SECONDS, jobScheduler); + + public OneShotEvent AutoRespawnInTown { get; } = new(PlayerAutoRespawnDelay, + target => { + if (target.Dead) target.Respawn(true); + }); + + public override void Dispose() + { + Cancel(SafeLogoutCountdown); + Cancel(AutoRespawnInTown); + base.Dispose(); + } +} diff --git a/src/Libraries/Game.Server/Systems/Events/RetryableEvent.cs b/src/Libraries/Game.Server/Systems/Events/RetryableEvent.cs new file mode 100644 index 00000000..11301e01 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/RetryableEvent.cs @@ -0,0 +1,52 @@ +using QuantumCore.API.Game.World; +using QuantumCore.API.Systems.Events; +using QuantumCore.Core.Event; +using Serilog; + +namespace QuantumCore.Game.Systems.Events; + +// convention: the callback returns false if retry is needed +public sealed class RetryableEvent( + TimeSpan retryDelay, + Func callback, + int maxRetries = 99) + : ISchedulable where TEntity : IEntity +{ + public long? EventId { get; set; } + + public long EnqueueEvent(TEntity entity, TArgs arg) + { + var remainingRetries = maxRetries; + return EventSystem.EnqueueEvent(() => + { + var succeeded = callback(entity, arg); + if (succeeded) + { + EventId = null; + return TimeSpan.Zero; + } + + if (remainingRetries-- > 0) + { + return retryDelay; + } + + Log.Error("Retryable event id {Id} for entity {Entity}: exhausted all {MaxRetries} retries", + EventId, entity, maxRetries); + EventId = null; + return TimeSpan.Zero; + }, TimeSpan.Zero); + } + + public bool Cancel() + { + if (!EventId.HasValue) + { + return false; + } + + EventSystem.CancelEvent(EventId.Value); + EventId = null; + return true; + } +} diff --git a/src/Libraries/Game.Server/Systems/Events/SafeLogoutCountdownEvent.cs b/src/Libraries/Game.Server/Systems/Events/SafeLogoutCountdownEvent.cs new file mode 100644 index 00000000..5e5f25d7 --- /dev/null +++ b/src/Libraries/Game.Server/Systems/Events/SafeLogoutCountdownEvent.cs @@ -0,0 +1,97 @@ +using QuantumCore.API.Core.Event; +using QuantumCore.API.Core.Timekeeping; +using QuantumCore.API.Game.World; +using QuantumCore.API.Systems.Events; +using QuantumCore.Core.Event; +using Serilog; + +namespace QuantumCore.Game.Systems.Events; + +public sealed class SafeLogoutCountdownEvent( + int normalWaitSeconds, + int combatWaitSeconds, + int combatGraceSeconds, + IJobScheduler scheduler) + : ISchedulable +{ + public long? EventId { get; set; } + + public readonly record struct Args( + string CountdownMessageTemplate, + Func OnCompleteAsync); + + public long EnqueueEvent(IPlayerEntity player, Args args) + { + var schedulingTime = player.Connection.Server.Clock.Now; + var remainingSeconds = ComputeCountdownSeconds(player, schedulingTime); + + return EventSystem.EnqueueEvent(() => + { + if (LastCombatActivity(player) > schedulingTime) + { + player.SendChatInfo("In combat, cancelled."); + EventId = null; + return TimeSpan.Zero; + } + + if (remainingSeconds > 0) + { + player.SendChatInfo(string.Format(args.CountdownMessageTemplate, remainingSeconds)); + remainingSeconds--; + + return TimeSpan.FromSeconds(1); + } + + scheduler.Schedule(async () => + { + try + { + await args.OnCompleteAsync(); + } + catch (Exception ex) + { + Log.Error(ex, "[{CountdownEvent}] Completion callback threw exception for player {Player}", + nameof(SafeLogoutCountdownEvent), player); + } + }); + EventId = null; + return TimeSpan.Zero; + }, TimeSpan.Zero); + } + + public bool Cancel() + { + if (!EventId.HasValue) + { + return false; + } + + EventSystem.CancelEvent(EventId.Value); + EventId = null; + return true; + } + + private int ComputeCountdownSeconds(IPlayerEntity player, ServerTimestamp schedulingTime) + { + var clock = player.Connection.Server.Clock; + if (LastCombatActivity(player) is { } lastCombatTime) + { + var combatExpirationTime = clock.Advance(lastCombatTime, TimeSpan.FromSeconds(combatGraceSeconds)); + if (schedulingTime < combatExpirationTime) + { + return combatWaitSeconds; + } + } + + return normalWaitSeconds; + } + + private static ServerTimestamp? LastCombatActivity(IPlayerEntity player) + { + // TODO: extend for trade, shop open, other interactions + return player.Timeline.LatestOf( + PlayerTimestampKind.DAMAGE_DEALT, + PlayerTimestampKind.DAMAGE_TAKEN, + PlayerTimestampKind.USED_SKILL); + } +} diff --git a/src/Libraries/Game.Server/World/AI/SimpleBehaviour.cs b/src/Libraries/Game.Server/World/AI/SimpleBehaviour.cs index 0d461391..50f37674 100644 --- a/src/Libraries/Game.Server/World/AI/SimpleBehaviour.cs +++ b/src/Libraries/Game.Server/World/AI/SimpleBehaviour.cs @@ -16,6 +16,7 @@ using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; using QuantumCore.Game.World.Entities; +using static QuantumCore.Game.World.AI.SimpleBehaviourUtils.CombatEvent; namespace QuantumCore.Game.World.AI; @@ -29,11 +30,10 @@ public class SimpleBehaviour : IBehaviour private int _spawnX; private int _spawnY; - private TimeSpan _attackCooldown; private int _lastAttackX; private int _lastAttackY; - private ServerTimestamp? _lastAttackTime; - private ServerTimestamp? _lastChangeAttackPositionTime; + + private readonly TimestampRegistry _timeline = new(); public IEntity? Target { get; set; } private readonly Dictionary _damageMap = new(); @@ -73,8 +73,6 @@ public void Init(IEntity entity) _spawnX = entity.PositionX; _spawnY = entity.PositionY; IsAggressive = entity is MonsterEntity mob && mob.Proto.AiFlag.HasAnyFlags(EAiFlags.AGGRESSIVE); - _lastAttackTime = null; - _lastChangeAttackPositionTime = null; } private void CalculateNextMovement() @@ -160,9 +158,9 @@ public void Update(TickContext ctx) targetLost = true; } - if (!targetLost && _lastAttackTime.HasValue) + if (!targetLost && _timeline[LAST_TOOK_DAMAGE] is { } lastTookDamage) { - if (ctx.ElapsedSince(_lastAttackTime.Value) > TimeSpan.FromMilliseconds(RETURN_TIMEOUT_MS)) + if (ctx.ElapsedSince(lastTookDamage) > TimeSpan.FromMilliseconds(RETURN_TIMEOUT_MS)) { if (_proto.AttackRange < _entity.DistanceTo(Target)) { @@ -189,7 +187,7 @@ public void Update(TickContext ctx) if (Target is null) { - _lastAttackTime = null; + _timeline[LAST_TOOK_DAMAGE] = null; ResetChangeAttackPositionTimer(ctx); TryGoto(new Coordinates((uint)_spawnX, (uint)_spawnY), ctx.Timestamp); } @@ -216,11 +214,10 @@ public void Update(TickContext ctx) } else { - _attackCooldown -= ctx.Delta; - if (_attackCooldown <= TimeSpan.Zero) + if (_timeline.UpdateIfElapsed(ctx, + LAST_ATTACK, TimeSpan.FromSeconds(2))) // todo use attack speed { Attack(Target, ctx); - _attackCooldown += TimeSpan.FromSeconds(2); // todo use attack speed } } } @@ -280,7 +277,7 @@ private bool TryChangeAttackPosition(IEntity target, double currentDistance, dou return false; } - _lastChangeAttackPositionTime = now; + _timeline[LAST_CHANGE_ATTACK_POSITION] = now; var rotationFromTarget = MathUtils.Rotation(_entity.PositionX - target.PositionX, _entity.PositionY - target.PositionY); @@ -327,7 +324,7 @@ private bool ShouldChangeAttackPosition(double currentDistance, TickContext ctx) changeInterval = TimeSpan.FromMilliseconds(CHANGE_ATTACK_POSITION_TIME_NEAR_MS); } - return ctx.ElapsedSince(_lastChangeAttackPositionTime) > changeInterval; + return ctx.ElapsedSince(_timeline[LAST_CHANGE_ATTACK_POSITION]) > changeInterval; } private double GetPreferredApproachDistance() @@ -347,7 +344,7 @@ private double GetPreferredApproachDistance() private void ResetChangeAttackPositionTimer(TickContext ctx) { - _lastChangeAttackPositionTime = + _timeline[LAST_CHANGE_ATTACK_POSITION] = ctx.Rewind(TimeSpan.FromMilliseconds(CHANGE_ATTACK_POSITION_TIME_NEAR_MS)); } @@ -408,7 +405,7 @@ public void TookDamage(IEntity attacker, uint damage) { if (_entity is null) return; - _lastAttackTime = (_entity.Map as Map)!.Clock.Now; + _timeline[LAST_TOOK_DAMAGE] = (_entity.Map as Map)!.Clock.Now; _lastAttackX = _entity.PositionX; _lastAttackY = _entity.PositionY; @@ -450,3 +447,15 @@ public void OnNewNearbyEntity(IEntity entity) } } } + +internal static class SimpleBehaviourUtils +{ + // located here as a workaround for readability - omitting qualifiers in main class + internal enum CombatEvent + { + LAST_ATTACK, + LAST_CHANGE_ATTACK_POSITION, + LAST_TOOK_DAMAGE + } + +} diff --git a/src/Libraries/Game.Server/World/AI/StoneBehaviour.cs b/src/Libraries/Game.Server/World/AI/StoneBehaviour.cs index 25549c4e..6c3814dd 100644 --- a/src/Libraries/Game.Server/World/AI/StoneBehaviour.cs +++ b/src/Libraries/Game.Server/World/AI/StoneBehaviour.cs @@ -87,11 +87,12 @@ public void TookDamage(IEntity attacker, uint damage) { if (spawnedEntity.Health > 0) { - spawnedEntity.Die(); + spawnedEntity.TryKnockout(); } } _spawnedEntities.Clear(); + _entity.TryKnockout(); } } diff --git a/src/Libraries/Game.Server/World/Entities/Entity.cs b/src/Libraries/Game.Server/World/Entities/Entity.cs index 2b0ec12a..d34b3c12 100644 --- a/src/Libraries/Game.Server/World/Entities/Entity.cs +++ b/src/Libraries/Game.Server/World/Entities/Entity.cs @@ -5,14 +5,15 @@ using QuantumCore.API.Game.Types.Combat; using QuantumCore.API.Game.Types.Entities; using QuantumCore.API.Game.World; -using QuantumCore.Core.Constants; using QuantumCore.Core.Utils; using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; +using QuantumCore.Game.Systems.Events; +using static QuantumCore.Game.Systems.Events.EntityEventRegistryBase; namespace QuantumCore.Game.World.Entities; -public abstract class Entity : IEntity +public abstract class Entity : IEntity, IDisposable { private readonly IAnimationManager _animationManager; public uint Vid { get; } @@ -21,6 +22,7 @@ public abstract class Entity : IEntity public uint EntityClass { get; protected set; } public EEntityState State { get; protected set; } public virtual IEntity? Target { get; set; } + protected abstract EntityEventRegistryBase BaseEvents { get; } public int PositionX { @@ -87,6 +89,8 @@ public bool PositionChanged private bool _positionChanged; protected PlayerEntity? LastAttacker { get; private set; } + protected bool IsIncapacitated => Dead || Health <= 0 || IsScheduled(BaseEvents.KnockoutDeath); + public Entity(IAnimationManager animationManager, uint vid) { _animationManager = animationManager; @@ -136,6 +140,10 @@ public virtual void Goto(int x, int y, ServerTimestamp startAt) { if (PositionX == x && PositionY == y) return; if (TargetPositionX == x && TargetPositionY == y) return; + if (IsIncapacitated) + { + return; + } var animation = _animationManager.GetAnimation(EntityClass, AnimationType.RUN, AnimationSubType.GENERAL); @@ -195,14 +203,29 @@ public void Stop() public abstract void SetPoint(EPoint point, uint value); public abstract uint GetPoint(EPoint point); - public void Attack(IEntity victim) + protected virtual bool TryBeginAttack(IEntity victim) { + if (IsIncapacitated) + { + return false; + } + if (this.PositionIsAttr(EMapAttributes.NON_PVP)) { - return; + return false; } if (victim.PositionIsAttr(EMapAttributes.NON_PVP)) + { + return false; + } + + return true; + } + + public void Attack(IEntity victim) + { + if (!TryBeginAttack(victim)) { return; } @@ -329,16 +352,6 @@ private int CalculateAttackBonus(IEntity victim, int attack) return attack; } - private int CalculateExperience(uint playerLevel) - { - var baseExp = GetPoint(EPoint.EXPERIENCE); - var entityLevel = GetPoint(EPoint.LEVEL); - - var percentage = ExperienceConstants.GetExperiencePercentageByLevelDifference(playerLevel, entityLevel); - - return (int)(baseExp * percentage); - } - private void SendDebugDamage(IEntity other, string text) { if (this is PlayerEntity thisPlayer) @@ -354,6 +367,10 @@ private void SendDebugDamage(IEntity other, string text) public virtual int Damage(IEntity attacker, EDamageType damageType, int damage) { + if (IsIncapacitated) + { + return -1; + } if (this.PositionIsAttr(EMapAttributes.NON_PVP)) { @@ -423,18 +440,22 @@ public virtual int Damage(IEntity attacker, EDamageType damageType, int damage) var attackerPlayer = attacker as PlayerEntity; if (victimPlayer is not null || attackerPlayer is not null) { - var damageInfo = new DamageInfo(); - damageInfo.Vid = Vid; - damageInfo.Damage = damage; - damageInfo.DamageFlags = damageFlags; + var damageInfo = new DamageInfo + { + Vid = Vid, + Damage = damage, + DamageFlags = damageFlags + }; - if (victimPlayer is not null) + if (victimPlayer is { Timeline: var victimTimeline }) { + victimTimeline[PlayerTimestampKind.DAMAGE_TAKEN] = victimPlayer.Connection.Server.Clock.Now; victimPlayer.Connection.Send(damageInfo); } - if (attackerPlayer is not null) + if (attackerPlayer is { Timeline: var attackerTimeline }) { + attackerTimeline[PlayerTimestampKind.DAMAGE_DEALT] = attackerPlayer.Connection.Server.Clock.Now; attackerPlayer.Connection.Send(damageInfo); LastAttacker = attackerPlayer; } @@ -453,23 +474,35 @@ public virtual int Damage(IEntity attacker, EDamageType damageType, int damage) if (Health <= 0) { - Die(); - if (Type != EEntityType.PLAYER && attackerPlayer is not null) - { - var exp = CalculateExperience(attackerPlayer.GetPoint(EPoint.LEVEL)); - attackerPlayer.AddPoint(EPoint.EXPERIENCE, exp); - attackerPlayer.SendPoints(); - } + TryKnockout(); } return damage; } + + public bool TryKnockout() + { + // already dead + if (IsScheduled(BaseEvents.KnockoutDeath) || Dead) + { + return false; + } + + Health = 0; + Stop(); + this.BroadcastNearby(new KnockoutCharacter { Vid = Vid }); + BaseEvents.Schedule(BaseEvents.KnockoutDeath); + + return true; + } public virtual void Die() { Dead = true; + Cancel(BaseEvents.KnockoutDeath); } + public void AddNearbyEntity(IEntity entity) { _nearbyEntities.Add(entity); @@ -491,4 +524,9 @@ public void ForEachNearbyEntity(Action action) action(entity); } } + + public virtual void Dispose() + { + BaseEvents.Dispose(); + } } diff --git a/src/Libraries/Game.Server/World/Entities/GroundItem.cs b/src/Libraries/Game.Server/World/Entities/GroundItem.cs index 17fda41c..1596dbd5 100644 --- a/src/Libraries/Game.Server/World/Entities/GroundItem.cs +++ b/src/Libraries/Game.Server/World/Entities/GroundItem.cs @@ -3,7 +3,9 @@ using QuantumCore.API.Game.Types.Combat; using QuantumCore.API.Game.Types.Entities; using QuantumCore.API.Game.World; +using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; +using QuantumCore.Game.Systems.Events; namespace QuantumCore.Game.World.Entities; @@ -11,18 +13,25 @@ public class GroundItem : Entity, IGroundItem { private readonly ItemInstance _item; private readonly uint _amount; - private readonly string? _ownerName; + private string? _ownerName; public ItemInstance Item => _item; public uint Amount => _amount; public string? OwnerName => _ownerName; + private GroundItemEventRegistry Events { get; } + protected override EntityEventRegistryBase BaseEvents => Events; + + public GroundItem(IAnimationManager animationManager, uint vid, ItemInstance item, uint amount, string? ownerName = null) : base(animationManager, vid) { _item = item; _amount = amount; _ownerName = ownerName; + Events = new GroundItemEventRegistry(this); + Events.Schedule(Events.OwnershipExpiry); + Events.Schedule(Events.LifetimeExpiry); } public override EEntityType Type { get; } @@ -55,6 +64,17 @@ public override void HideEntity(IConnection connection) connection.Send(new GroundItemRemove {Vid = Vid}); } + public bool ReleaseOwnership() + { + var hadOwner = _ownerName is not null; + + _ownerName = null; + var clearOwnerPacket = new ItemOwnership { Vid = Vid, Player = "" }; + this.BroadcastNearby(clearOwnerPacket); + + return hadOwner; + } + public override uint GetPoint(EPoint point) { throw new NotImplementedException(); diff --git a/src/Libraries/Game.Server/World/Entities/MonsterEntity.cs b/src/Libraries/Game.Server/World/Entities/MonsterEntity.cs index 3267673a..9cd9cbda 100644 --- a/src/Libraries/Game.Server/World/Entities/MonsterEntity.cs +++ b/src/Libraries/Game.Server/World/Entities/MonsterEntity.cs @@ -9,9 +9,12 @@ using QuantumCore.API.Game.Types.Players; using QuantumCore.API.Game.World; using QuantumCore.API.Game.World.AI; +using QuantumCore.Core.Constants; using QuantumCore.Core.Utils; +using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; using QuantumCore.Game.Services; +using QuantumCore.Game.Systems.Events; using QuantumCore.Game.World.AI; namespace QuantumCore.Game.World.Entities; @@ -23,6 +26,9 @@ public class MonsterEntity : Entity public override EEntityType Type => EEntityType.MONSTER; public bool IsStone => Proto.Type == (byte)EEntityType.METIN_STONE; public EMonsterLevel Rank => (EMonsterLevel)Proto.Rank; + private MonsterEventRegistry Events { get; } + protected override EntityEventRegistryBase BaseEvents => Events; + public override IEntity? Target { @@ -60,7 +66,6 @@ public override byte HealthPercentage private IBehaviour? _behaviour; private bool _behaviourInitialized; - private ServerTimestamp? _diedAt; private readonly IMap _map; private readonly IItemManager _itemManager; private IServiceProvider _serviceProvider; @@ -84,6 +89,7 @@ public MonsterEntity(IMonsterManager monsterManager, IDropProvider dropProvider, _serviceProvider = serviceProvider; _logger = logger; _itemManager = itemManager; + Events = new MonsterEventRegistry(this); Proto = proto; PositionX = x; PositionY = y; @@ -112,23 +118,18 @@ public MonsterEntity(IMonsterManager monsterManager, IDropProvider dropProvider, public override void Update(TickContext ctx) { if (Map is null) return; - if (Dead) - { - if (!_diedAt.HasValue) - { - _diedAt = ctx.Timestamp; - } - else if (ctx.ElapsedSince(_diedAt.Value) >= TimeSpan.FromSeconds(5)) - { - Map.DespawnEntity(this); - } - } if (!_behaviourInitialized) { _behaviour?.Init(this); _behaviourInitialized = true; } + + if (IsIncapacitated) + { + Stop(); + return; + } if (!Dead) { @@ -240,20 +241,43 @@ public override void Die() return; } + CalculateExperience(); CalculateDrops(); base.Die(); + Events.Schedule(Events.DespawnAfterDeath); - var dead = new CharacterDead {Vid = Vid}; - foreach (var entity in NearbyEntities) + this.BroadcastNearby(new CharacterDead { Vid = Vid }); + // clear target UI for all players targeting this entity + var clearTargetPacket = new SetTarget { TargetVid = 0 }; + foreach (var targetingPlayer in TargetedBy) { - if (entity is PlayerEntity player) - { - player.Connection.Send(dead); - } + targetingPlayer.Connection.Send(clearTargetPacket); } + TargetedBy.Clear(); } + + private void CalculateExperience() + { + // no exp if no killer + if (LastAttacker is not { } killer) + { + return; + } + + var baseExp = GetPoint(EPoint.EXPERIENCE); + var entityLevel = GetPoint(EPoint.LEVEL); + var percentage = ExperienceConstants.GetExperiencePercentageByLevelDifference( + killer.GetPoint(EPoint.LEVEL), entityLevel); + + var exp = (int)(baseExp * percentage); + + // TODO: send animation packet for flying exp orbs + killer.AddPoint(EPoint.EXPERIENCE, exp); + killer.SendPoints(); + } + private void CalculateDrops() { // no drops if no killer diff --git a/src/Libraries/Game.Server/World/Entities/PlayerEntity.cs b/src/Libraries/Game.Server/World/Entities/PlayerEntity.cs index 01686017..b3174f01 100644 --- a/src/Libraries/Game.Server/World/Entities/PlayerEntity.cs +++ b/src/Libraries/Game.Server/World/Entities/PlayerEntity.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using QuantumCore.API; +using QuantumCore.API.Core.Event; using QuantumCore.API.Core.Models; using QuantumCore.API.Core.Timekeeping; using QuantumCore.API.Extensions; @@ -17,19 +18,26 @@ using QuantumCore.API.Game.World; using QuantumCore.Caching; using QuantumCore.Extensions; +using QuantumCore.Game.Constants; using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; using QuantumCore.Game.Packets.Guild; using QuantumCore.Game.Persistence; using QuantumCore.Game.PlayerUtils; using QuantumCore.Game.Skills; +using QuantumCore.Game.Systems.Events; namespace QuantumCore.Game.World.Entities; -public class PlayerEntity : Entity, IPlayerEntity, IDisposable +public class PlayerEntity : Entity, IPlayerEntity { public override EEntityType Type => EEntityType.PLAYER; + public PlayerEventRegistry Events { get; } + protected override EntityEventRegistryBase BaseEvents => Events; + + public TimestampRegistry Timeline { get; } = new(); + public string Name => Player.Name; public IGameConnection Connection { get; } public PlayerData Player { get; private set; } @@ -88,12 +96,6 @@ public EAntiFlags AntiFlagGender private uint _defence; - private const int PERSIST_INTERVAL = 30 * 1000; // 30s - private ServerTimestamp? _lastPersistTime; - private const int HEALTH_REGEN_INTERVAL = 3 * 1000; - private const int MANA_REGEN_INTERVAL = 3 * 1000; - private ServerTimestamp? _lastHealthRegenTime; - private ServerTimestamp? _lastManaRegenTime; private readonly IItemManager _itemManager; private readonly IJobManager _jobManager; private readonly IExperienceManager _experienceManager; @@ -105,7 +107,7 @@ public EAntiFlags AntiFlagGender private readonly IItemRepository _itemRepository; public PlayerEntity(PlayerData player, IGameConnection connection, IItemManager itemManager, - IJobManager jobManager, + IJobManager jobManager, IJobScheduler jobScheduler, IExperienceManager experienceManager, IAnimationManager animationManager, IQuestManager questManager, ICacheManager cacheManager, IWorld world, ILogger logger, IServiceProvider serviceProvider) @@ -137,6 +139,7 @@ public PlayerEntity(PlayerData player, IGameConnection connection, IItemManager EntityClass = (uint)player.PlayerClass; Groups = new List(); + Events = new PlayerEventRegistry(this, jobScheduler); } private static uint GetMaxSp(IJobManager jobManager, EPlayerClassGendered playerClass, byte level, uint point) @@ -324,6 +327,8 @@ public override void Die() } base.Die(); + Timeline[PlayerTimestampKind.DIED] = Connection.Server.Clock.Now; + Events.Schedule(Events.AutoRespawnInTown); var dead = new CharacterDead {Vid = Vid}; foreach (var entity in NearbyEntities) @@ -365,15 +370,18 @@ public void Respawn(bool town) return; } + Events.Cancel(Events.AutoRespawnInTown); Shop?.Close(this); Dead = false; + Timeline[PlayerTimestampKind.RESPAWNED] = Connection.Server.Clock.Now; if (town) { var townCoordinates = Map!.TownCoordinates; if (townCoordinates is not null) { + // TODO: show map loading screen in client (it's jarring to be TPed instantly across the map) Move(Player.Empire switch { EEmpire.CHUNJO => townCoordinates.Chunjo, @@ -383,9 +391,15 @@ public void Respawn(bool town) $"Can't get empire coordinates for empire {Player.Empire}") }); } + else + { + _logger.LogDebug("Cannot get {TownCoordinates} for {Respawn} in town, will fallback to here.", + nameof(Map.TownCoordinates), nameof(Respawn)); + } } // todo spawn with invisible affect + // TODO: penalize death by removing some EXP SendChatCommand("CloseRestartWindow"); Connection.SetPhase(EPhase.GAME); @@ -513,38 +527,26 @@ public override void Update(TickContext ctx) var hpOrSpChanged = false; var maxHp = GetPoint(EPoint.MAX_HP); - if (Health < maxHp && !Dead) + if (Health < maxHp && !IsIncapacitated) { - if (!_lastHealthRegenTime.HasValue) - { - // start counting interval only from first viable reset - _lastHealthRegenTime = ctx.Timestamp; - } - else if (ctx.ElapsedSince(_lastHealthRegenTime.Value) > TimeSpan.FromMilliseconds(HEALTH_REGEN_INTERVAL)) + if (Timeline.UpdateIfElapsed(ctx, + PlayerTimestampKind.RESTORED_HEALTH, SchedulingConstants.PlayerHealthRegenInterval)) { var factor = State == EEntityState.IDLE ? 0.05 : 0.01; Health = Math.Min((int)maxHp, Health + 15 + (int)(maxHp * factor)); hpOrSpChanged = true; - - _lastHealthRegenTime = ctx.Timestamp; } } var maxSp = GetPoint(EPoint.MAX_SP); - if (Mana < maxSp && !Dead) + if (Mana < maxSp && !IsIncapacitated) { - if (!_lastManaRegenTime.HasValue) - { - // start counting interval only from first viable reset - _lastManaRegenTime = ctx.Timestamp; - } - else if (ctx.ElapsedSince(_lastManaRegenTime.Value) > TimeSpan.FromMilliseconds(MANA_REGEN_INTERVAL)) + if (Timeline.UpdateIfElapsed(ctx, + PlayerTimestampKind.RESTORED_MANA, SchedulingConstants.PlayerManaRegenInterval)) { var factor = State == EEntityState.IDLE ? 0.05 : 0.01; Mana = Math.Min((int)maxSp, Mana + 15 + (int)(maxSp * factor)); hpOrSpChanged = true; - - _lastManaRegenTime = ctx.Timestamp; } } @@ -553,14 +555,10 @@ public override void Update(TickContext ctx) SendPoints(); } - if (!_lastPersistTime.HasValue) - { - _lastPersistTime = ctx.Timestamp; - } - else if (ctx.ElapsedSince(_lastPersistTime.Value) > TimeSpan.FromMilliseconds(PERSIST_INTERVAL)) + if (Timeline.UpdateIfElapsed(ctx, + PlayerTimestampKind.AUTOSAVED, SchedulingConstants.PlayerAutosaveInterval)) { Persist().Wait(); // TODO - _lastPersistTime = ctx.Timestamp; } } @@ -849,6 +847,17 @@ public override uint GetPoint(EPoint point) } } + protected override bool TryBeginAttack(IEntity victim) + { + var canAttack = base.TryBeginAttack(victim); + if (canAttack) + { + Timeline[PlayerTimestampKind.ATTACK_INITIATED] = Connection.Server.Clock.Now; + } + + return canAttack; + } + private async Task Persist() { await QuickSlotBar.Persist(); @@ -981,7 +990,7 @@ public int GetMobItemRate() // todo: implement server rates, and premium server rates if (GetPremiumRemainSeconds(EPremiumType.ITEM) > 0) return 100; - return 100_000_000; + return 1_000_000; } public int GetPremiumRemainSeconds(EPremiumType type) @@ -1374,8 +1383,9 @@ public override string ToString() return Player.Name + "(Player)"; } - public void Dispose() + public override void Dispose() { + base.Dispose(); _scope.Dispose(); } } diff --git a/src/Tests/Game.Tests/CommandTests.cs b/src/Tests/Game.Tests/CommandTests.cs index 041d8140..aad249ad 100644 --- a/src/Tests/Game.Tests/CommandTests.cs +++ b/src/Tests/Game.Tests/CommandTests.cs @@ -13,6 +13,7 @@ using Microsoft.Extensions.Time.Testing; using NSubstitute; using QuantumCore.API; +using QuantumCore.API.Core.Event; using QuantumCore.API.Core.Models; using QuantumCore.API.Core.Timekeeping; using QuantumCore.API.Game; @@ -24,10 +25,12 @@ using QuantumCore.API.Game.Types.Skills; using QuantumCore.API.Game.World; using QuantumCore.Caching; +using QuantumCore.Core.Event; using QuantumCore.Core.Packets; using QuantumCore.Extensions; using QuantumCore.Game; using QuantumCore.Game.Commands; +using QuantumCore.Game.Constants; using QuantumCore.Game.Extensions; using QuantumCore.Game.Packets; using QuantumCore.Game.Packets.Skills; @@ -91,6 +94,11 @@ public bool HandleHandshake(GcHandshakeData handshake) } } +internal sealed class DeterministicScheduler : IJobScheduler +{ + public void Schedule(Func work) => work().GetAwaiter().GetResult(); +} + public class CommandTests : IAsyncLifetime { private readonly ICommandManager _commandManager; @@ -182,6 +190,8 @@ public CommandTests(ITestOutputHelper testOutputHelper) .AddSingleton(Substitute.For()) .AddQuantumCoreTestLogger(testOutputHelper) .Replace(new ServiceDescriptor(typeof(TimeProvider), _ => _timeProvider, ServiceLifetime.Singleton)) + .Replace(new ServiceDescriptor(typeof(IJobScheduler), _ => new DeterministicScheduler(), + ServiceLifetime.Singleton)) .Replace(new ServiceDescriptor(typeof(IItemRepository), _ => Substitute.For(), ServiceLifetime.Singleton)) .Replace(new ServiceDescriptor(typeof(ICommandPermissionRepository), @@ -268,6 +278,7 @@ public async Task CommandTeleportTo() { var world = await PrepareWorldAsync(); var player2 = ActivatorUtilities.CreateInstance(_services, _playerDataFaker.Generate()); + await player2.Load(); world.SpawnEntity(_player); world.SpawnEntity(player2); world.Update(Tick()); // spawn entities @@ -287,6 +298,7 @@ public async Task CommandTeleportHere() { var world = await PrepareWorldAsync(); var player2 = ActivatorUtilities.CreateInstance(_services, _playerDataFaker.Generate()); + await player2.Load(); world.SpawnEntity(_player); world.SpawnEntity(player2); world.Update(Tick()); // spawn entities @@ -511,11 +523,19 @@ public async Task LogoutCommand() world.GetPlayer(_player.Name).Should().NotBeNull(); _player.Player.PlayTime = 0; - _timeProvider.Advance(TimeSpan.FromMinutes(1)); + _timeProvider.Advance(TimeSpan.FromMinutes(37)); await _commandManager.Handle(_connection, "/logout"); - _player.GetPoint(EPoint.PLAY_TIME).Should().Be(1); + // fast forward the logout safety countdown + // (eventually EventSystem should be reworked so that it reuses the same source _timeProvider and not require this anymore) + EventSystem.Update(Tick()); + for (var i = 0; i < SchedulingConstants.LOGOUT_WAIT_SECONDS; i++) + { + EventSystem.Update(Tick(1000)); + } + + _player.GetPoint(EPoint.PLAY_TIME).Should().Be(37); world.GetPlayer(_player.Name).Should().BeNull(); } @@ -530,11 +550,18 @@ public async Task PhaseSelectCommand() .NotContainEquivalentOf(new GcPhase { Phase = EPhase.SELECT }); _player.Player.PlayTime = 0; - _timeProvider.Advance(TimeSpan.FromMinutes(1)); + _timeProvider.Advance(TimeSpan.FromMinutes(37)); await _commandManager.Handle(_connection, "/phase_select"); - _player.GetPoint(EPoint.PLAY_TIME).Should().Be(1); + // fast forward the logout safety countdown + EventSystem.Update(Tick()); + for (var i = 0; i < SchedulingConstants.LOGOUT_WAIT_SECONDS; i++) + { + EventSystem.Update(Tick(1000)); + } + + _player.GetPoint(EPoint.PLAY_TIME).Should().Be(37); _player.Connection.Phase.Should().Be(EPhase.SELECT); (_connection as MockedGameConnection).SentPhases.Should() .ContainEquivalentOf(new GcPhase { Phase = EPhase.SELECT }); @@ -551,6 +578,13 @@ public async Task QuitCommand() await _commandManager.Handle(_connection, "/quit"); + // fast forward the logout safety countdown + EventSystem.Update(Tick()); + for (var i = 0; i < SchedulingConstants.LOGOUT_WAIT_SECONDS; i++) + { + EventSystem.Update(Tick(1000)); + } + world.GetPlayer(_player.Name).Should().BeNull(); } @@ -566,6 +600,7 @@ public async Task RestartHereCommand() _player.PositionY.Should().Be(665600); _player.Die(); + _timeProvider.Advance(SchedulingConstants.RestartHereMinWait); await _commandManager.Handle(_connection, "/restart_here"); _player.Health.Should().Be(PlayerConstants.RESPAWN_HEALTH); @@ -588,6 +623,7 @@ public async Task RestartTownCommand() _player.PositionY.Should().Be(665600); _player.Die(); + _timeProvider.Advance(SchedulingConstants.RestartTownMinWait); await _commandManager.Handle(_connection, "/restart_town"); _player.Health.Should().Be(PlayerConstants.RESPAWN_HEALTH);