diff --git a/ReallifeGamemode.Client/package-lock.json b/ReallifeGamemode.Client/package-lock.json
index d39f08d2..34a97af4 100644
--- a/ReallifeGamemode.Client/package-lock.json
+++ b/ReallifeGamemode.Client/package-lock.json
@@ -346,7 +346,10 @@
"NativeUI": {
"version": "git+https://github.com/sprayzcs/RageMP-NativeUI.git#3f2412469f0be4dfd8f7ff495aac67949704bff6",
"from": "git+https://github.com/sprayzcs/RageMP-NativeUI.git",
- "dev": true
+ "dev": true,
+ "requires": {
+ "ajv": "^6.8.1"
+ }
},
"acorn": {
"version": "6.2.1",
@@ -1647,8 +1650,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"aproba": {
"version": "1.2.0",
@@ -1669,14 +1671,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@@ -1691,20 +1691,17 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"core-util-is": {
"version": "1.0.2",
@@ -1821,8 +1818,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"ini": {
"version": "1.3.5",
@@ -1834,7 +1830,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@@ -1849,7 +1844,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@@ -1857,14 +1851,12 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"minipass": {
"version": "2.3.5",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@@ -1883,7 +1875,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"minimist": "0.0.8"
}
@@ -1964,8 +1955,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -1977,7 +1967,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"wrappy": "1"
}
@@ -2063,8 +2052,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"safer-buffer": {
"version": "2.1.2",
@@ -2100,7 +2088,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@@ -2120,7 +2107,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
- "optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@@ -2164,14 +2150,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
- "dev": true,
- "optional": true
+ "dev": true
}
}
},
diff --git a/ReallifeGamemode.DataService/ReallifeGamemode.DataService.csproj b/ReallifeGamemode.DataService/ReallifeGamemode.DataService.csproj
index b8dbd337..4907947c 100644
--- a/ReallifeGamemode.DataService/ReallifeGamemode.DataService.csproj
+++ b/ReallifeGamemode.DataService/ReallifeGamemode.DataService.csproj
@@ -7,6 +7,7 @@
true
true
AnyCPU;x64
+ 8.0
@@ -18,21 +19,15 @@
-
-
+
-
-
-
-
-
-
+
diff --git a/ReallifeGamemode.DataService/Startup.cs b/ReallifeGamemode.DataService/Startup.cs
index efdcea70..ce5d120e 100644
--- a/ReallifeGamemode.DataService/Startup.cs
+++ b/ReallifeGamemode.DataService/Startup.cs
@@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Logging;
@@ -26,9 +27,9 @@ namespace ReallifeGamemode.DataService
{
private readonly ILogger logger;
private readonly IConfiguration configuration;
- private readonly IHostingEnvironment environment;
+ private readonly IWebHostEnvironment environment;
- public Startup(IConfiguration configuration, IHostingEnvironment environment, ILogger logger)
+ public Startup(IConfiguration configuration,IWebHostEnvironment environment, ILogger logger)
{
this.configuration = configuration;
this.environment = environment;
@@ -122,15 +123,15 @@ namespace ReallifeGamemode.DataService
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
- public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory)
+ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, ILoggerFactory loggerFactory)
{
- app.UseAuthentication();
-
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}
+ app.UseAuthentication();
+
app.UseStaticFiles();
app.UseCors(c =>
@@ -151,7 +152,10 @@ namespace ReallifeGamemode.DataService
c.RouteTemplate = "doc/{documentName}.json";
});
- app.UseMvc();
+ app.UseEndpoints(endpoints =>
+ {
+ endpoints.MapControllers();
+ });
}
}
}
diff --git a/ReallifeGamemode.Database/Models/DatabaseContext.cs b/ReallifeGamemode.Database/Models/DatabaseContext.cs
index a5ad8d4f..d8d1c4ff 100644
--- a/ReallifeGamemode.Database/Models/DatabaseContext.cs
+++ b/ReallifeGamemode.Database/Models/DatabaseContext.cs
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
/**
* @overview Life of German Reallife - DatabaseContext.cs
@@ -10,13 +11,23 @@ namespace ReallifeGamemode.Database.Models
{
public partial class DatabaseContext : DbContext
{
+ private readonly ILoggerFactory loggerFactory;
+
public DatabaseContext(DbContextOptions options) : base(options) { }
- public DatabaseContext() { }
+ public DatabaseContext(ILoggerFactory loggerFactory = null) {
+ this.loggerFactory = loggerFactory;
+ }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
base.OnConfiguring(optionsBuilder);
+
+ if(loggerFactory != null)
+ {
+ optionsBuilder.UseLoggerFactory(loggerFactory);
+ }
+
optionsBuilder.UseMySql("Host=localhost;Port=3306;Database=gtav-devdb;Username=gtav-dev;Password=Test123");
}
diff --git a/ReallifeGamemode.Database/ReallifeGamemode.Database.csproj b/ReallifeGamemode.Database/ReallifeGamemode.Database.csproj
index ab543c94..4af8ed0d 100644
--- a/ReallifeGamemode.Database/ReallifeGamemode.Database.csproj
+++ b/ReallifeGamemode.Database/ReallifeGamemode.Database.csproj
@@ -3,6 +3,7 @@
netcoreapp3.1
AnyCPU;x64
+ 8.0
diff --git a/ReallifeGamemode.Server.Common/PasswordHasher.cs b/ReallifeGamemode.Server.Common/PasswordHasher.cs
new file mode 100644
index 00000000..01bc7a87
--- /dev/null
+++ b/ReallifeGamemode.Server.Common/PasswordHasher.cs
@@ -0,0 +1,40 @@
+using Microsoft.AspNetCore.Cryptography.KeyDerivation;
+using System;
+using System.Collections.Generic;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ReallifeGamemode.Server.Common
+{
+ public class PasswordHasher
+ {
+ public static byte[] GetNewSalt(int length = 256)
+ {
+ if ((length % 8) != 0)
+ {
+ throw new ArgumentException("Length mus be completely divisble through 8");
+ }
+
+ var salt = new byte[length / 8];
+
+ using (var rng = RandomNumberGenerator.Create())
+ {
+ rng.GetBytes(salt);
+ }
+
+ return salt;
+ }
+
+ public static string HashPassword(string password, byte[] salt, int length = 512)
+ {
+ if ((length % 8) != 0)
+ {
+ throw new ArgumentException("Length mus be completely divisble through 8");
+ }
+
+ var hashedPassword = KeyDerivation.Pbkdf2(password, salt, KeyDerivationPrf.HMACSHA512, 50000, length / 8);
+
+ return Convert.ToBase64String(hashedPassword);
+ }
+ }
+}
diff --git a/ReallifeGamemode.Server.Common/ReallifeGamemode.Server.Common.csproj b/ReallifeGamemode.Server.Common/ReallifeGamemode.Server.Common.csproj
new file mode 100644
index 00000000..e09130e6
--- /dev/null
+++ b/ReallifeGamemode.Server.Common/ReallifeGamemode.Server.Common.csproj
@@ -0,0 +1,21 @@
+
+
+
+ netstandard2.1
+
+
+
+
+
+
+
+
+
+
+
+
+ ..\Import\Newtonsoft.Json.dll
+
+
+
+
diff --git a/ReallifeGamemode.Server.Common/TypeExtensions.cs b/ReallifeGamemode.Server.Common/TypeExtensions.cs
new file mode 100644
index 00000000..94ae4bf9
--- /dev/null
+++ b/ReallifeGamemode.Server.Common/TypeExtensions.cs
@@ -0,0 +1,114 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+using ReallifeGamemode.Server.Types;
+using System;
+using System.Linq;
+using System.Reflection;
+using System.Runtime.Serialization;
+
+namespace ReallifeGamemode.Server.Common
+{
+ public static class TypeExtensions
+ {
+ private static readonly JsonSerializerSettings settings = new JsonSerializerSettings()
+ {
+ NullValueHandling = NullValueHandling.Ignore,
+ DateFormatHandling = DateFormatHandling.MicrosoftDateFormat,
+ DateTimeZoneHandling = DateTimeZoneHandling.Utc,
+ DateParseHandling = DateParseHandling.DateTime
+ };
+
+ static TypeExtensions()
+ {
+ settings.Converters.Add(new StringEnumConverter());
+ }
+
+ public static string SerializeJson(this object obj)
+ {
+ if (obj == null)
+ {
+ throw new ArgumentNullException(nameof(obj));
+ }
+
+ return JsonConvert.SerializeObject(obj, settings);
+ }
+
+ public static T DeserializeJson(this string str)
+ {
+ if (str == null)
+ {
+ throw new ArgumentNullException(nameof(str));
+ }
+
+ return JsonConvert.DeserializeObject(str, settings);
+ }
+
+ public static bool IsNullOrEmpty(this string str)
+ {
+ if (str == null)
+ {
+ return true;
+ }
+
+ if (str.Trim().Length == 0)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public static bool IsNotNullOrEmpty(this string str) => !str.IsNullOrEmpty();
+
+ public static T GetEnumAttribute(this Enum @enum) where T : Attribute
+ {
+ var type = @enum?.GetType();
+ var memberInfo = type?.GetMember(@enum.ToString()).First();
+ return memberInfo?.GetCustomAttribute();
+ }
+
+ public static string GetValue(this Enum @enum)
+ {
+ return @enum?.GetEnumAttribute()?.Value;
+ }
+
+ public static string GetPrefix(this ChatPrefix prefix)
+ {
+ return prefix.GetValue();
+ }
+
+ public static bool TryParseEnum(this string str, Type type, out object @enum)
+ {
+ var members = Enum.GetValues(type).Cast();
+ var directEnumMember = members.Where(e => e.ToString().ToLower() == str.ToLower()).FirstOrDefault();
+
+ if (directEnumMember != null)
+ {
+ @enum = directEnumMember;
+ return true;
+ }
+
+ var memberValueMember = members.Where(e => e.GetValue()?.ToLower() == str.ToLower()).FirstOrDefault();
+
+ if (memberValueMember != null)
+ {
+ @enum = memberValueMember;
+ return true;
+ }
+
+ var nativeResult = Enum.TryParse(type, str, true, out var result);
+ @enum = result ?? (default);
+ return nativeResult;
+ }
+
+ public static long ToLong(this object obj)
+ {
+ return long.Parse(obj.ToString());
+ }
+
+ public static int ToInt(this object obj)
+ {
+ return (int)obj.ToLong();
+ }
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IAPI.cs b/ReallifeGamemode.Server.Core.API/IAPI.cs
new file mode 100644
index 00000000..c8528289
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IAPI.cs
@@ -0,0 +1,28 @@
+using ReallifeGamemode.Server.Core.API.API;
+
+using System;
+using System.Collections.Generic;
+using System.Text;
+using System.Threading.Tasks;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public interface IAPI
+ {
+ IColShapeAPI ColShape { get; }
+
+ IVehicleAPI Vehicle { get; }
+
+ void DisableDefaultCommandErrorMessages();
+
+ void DisableDefaultSpawnBehavior();
+
+ IPlayer GetPlayerFromNameOrId(string nameOrId);
+
+ void SetGlobalChatEnabled(bool enable);
+
+ void SetTime(int hour, int minute, int second);
+
+ void TriggerClientEventForAll(string eventName, params object[] args);
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IColShape.cs b/ReallifeGamemode.Server.Core.API/IColShape.cs
new file mode 100644
index 00000000..ba194363
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IColShape.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public interface IColShape : IEntity
+ {
+ delegate void ColShapeEvent(IColShape colShape, IEntity entity);
+
+ event ColShapeEvent OnEntityEnter;
+ event ColShapeEvent OnEntityExit;
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IColShapeAPI.cs b/ReallifeGamemode.Server.Core.API/IColShapeAPI.cs
new file mode 100644
index 00000000..baa02c22
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IColShapeAPI.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+
+namespace ReallifeGamemode.Server.Core.API.API
+{
+ public interface IColShapeAPI
+ {
+ IColShape CreateCyclinder(Position position, float height, float range);
+
+ IColShape CreateSphere(Position position, float range);
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IEntity.cs b/ReallifeGamemode.Server.Core.API/IEntity.cs
new file mode 100644
index 00000000..d2831f14
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IEntity.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public interface IEntity
+ {
+ ulong Handle { get; }
+
+ Position Position { get; set; }
+
+ Position Rotation { get; set; }
+
+ double Heading { get; set; }
+
+ void Remove();
+
+ void SetSharedData(string key, T data);
+
+ T GetSharedData(string key, T fallback);
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IPlayer.cs b/ReallifeGamemode.Server.Core.API/IPlayer.cs
new file mode 100644
index 00000000..ca4ae3f8
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IPlayer.cs
@@ -0,0 +1,43 @@
+using ReallifeGamemode.Server.Types;
+using ReallifeGamemode.Server.Common;
+using System.Net;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public interface IPlayer : IEntity
+ {
+ string Name { get; set; }
+
+ string SocialClubName { get; }
+
+ int Health { get; set; }
+
+ int Armor { get; set; }
+
+ IPAddress RemoteAddress { get; }
+
+ IVehicle Vehicle { get; }
+
+ bool IsInVehicle { get; }
+
+ VehicleSeat VehicleSeat { get; }
+
+ void SendMessage(string message, ChatPrefix prefix = ChatPrefix.None) => SendRawMessage(prefix.GetValue() + message);
+
+ void SendRawMessage(string message);
+
+ void TriggerEvent(string eventName, params object[] args) => TriggerEventRaw("SERVER:" + eventName, args);
+ void CancelAnimation();
+ void TriggerEventRaw(string eventName, params object[] args);
+
+ void SetIntoVehicle(IVehicle vehicle, VehicleSeat seat);
+
+ void Kick();
+
+ void SendNotification(string message, bool flashing = true);
+
+ void Spawn(Position position, float heading = 0.0f);
+
+ void PlayAnimation(string dict, string name, AnimationFlags flags, float speed = 8.0f);
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IVehicle.cs b/ReallifeGamemode.Server.Core.API/IVehicle.cs
new file mode 100644
index 00000000..ea9f00ac
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IVehicle.cs
@@ -0,0 +1,17 @@
+
+
+using ReallifeGamemode.Server.Types;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public interface IVehicle : IEntity
+ {
+ VehicleModel Model { get; }
+
+ sbyte PrimaryColor { get; set; }
+
+ sbyte SecondaryColor { get; set; }
+
+ void Repair();
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/IVehicleAPI.cs b/ReallifeGamemode.Server.Core.API/IVehicleAPI.cs
new file mode 100644
index 00000000..12e05293
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/IVehicleAPI.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Text;
+using ReallifeGamemode.Server.Types;
+
+namespace ReallifeGamemode.Server.Core.API.API
+{
+ public interface IVehicleAPI
+ {
+ IVehicle Spawn(VehicleModel model, Position position, Position rotation, sbyte primaryColor, sbyte secondaryColor);
+
+ IVehicle GetVehicleFromHandle(ulong handle);
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/Position.cs b/ReallifeGamemode.Server.Core.API/Position.cs
new file mode 100644
index 00000000..880372d7
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/Position.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Globalization;
+
+namespace ReallifeGamemode.Server.Core.API
+{
+ public class Position
+ {
+ public double X { get; set; }
+
+ public double Y { get; set; }
+
+ public double Z { get; set; }
+
+ public Position()
+ {
+ X = 0.0;
+ Y = 0.0;
+ Z = 0.0;
+ }
+
+ public Position(double x, double y, double z)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ }
+
+ public Position(float x, float y, float z)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ }
+ public Position(decimal x, decimal y, decimal z)
+ {
+ X = (double)x;
+ Y = (double)y;
+ Z = (double)z;
+ }
+
+ public Position(int x, int y, int z)
+ {
+ X = x;
+ Y = y;
+ Z = z;
+ }
+
+ public Position Add(Position toAdd)
+ {
+ return new Position(X + toAdd.X, Y + toAdd.Y, Z + toAdd.Z);
+ }
+
+ public Position Subtract(Position toSubtract)
+ {
+ return new Position(X - toSubtract.X, Y - toSubtract.Y, Z - toSubtract.Z);
+ }
+
+ public double DistanceTo(Position position)
+ {
+ var x = position.X - X;
+ var y = position.Y - Y;
+ var z = position.Z - Z;
+
+ return Math.Sqrt(x * x + y * y + z * z);
+ }
+
+ public override string ToString()
+ {
+ var x = Math.Round(this.X, 2).ToString(CultureInfo.InvariantCulture);
+ var y = Math.Round(this.Y, 2).ToString(CultureInfo.InvariantCulture);
+ var z = Math.Round(this.Z, 2).ToString(CultureInfo.InvariantCulture);
+
+ return $"{x}, {y}, {z}";
+ }
+ }
+}
diff --git a/ReallifeGamemode.Server.Core.API/ReallifeGamemode.Server.Core.API.csproj b/ReallifeGamemode.Server.Core.API/ReallifeGamemode.Server.Core.API.csproj
new file mode 100644
index 00000000..d155e553
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.API/ReallifeGamemode.Server.Core.API.csproj
@@ -0,0 +1,12 @@
+
+
+
+ netstandard2.1
+
+
+
+
+
+
+
+
diff --git a/ReallifeGamemode.Server.Core.Events/EventHandler.cs b/ReallifeGamemode.Server.Core.Events/EventHandler.cs
new file mode 100644
index 00000000..693f0fbf
--- /dev/null
+++ b/ReallifeGamemode.Server.Core.Events/EventHandler.cs
@@ -0,0 +1,76 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+using Microsoft.Extensions.Logging;
+using ReallifeGamemode.Server.Common;
+using ReallifeGamemode.Server.Core.API;
+using ReallifeGamemode.Server.Log;
+using ReallifeGamemode.Server.Types;
+
+namespace ReallifeGamemode.Server.Core.Events
+{
+ public class EventHandler
+ {
+ private readonly IAPI api;
+
+ public delegate void PlayerEvent(IPlayer player);
+ public delegate void VehicleEvent(IVehicle vehicle);
+ public delegate void PlayerVehicleEvent(IPlayer player, IVehicle vehicle);
+ public delegate void PlayerDisconnectEvent(IPlayer player, DisconnectReason reason, string reasonString);
+
+ public delegate void ClientEvent(IPlayer player, params object[] args);
+ private static readonly Dictionary clientEvents = new Dictionary();
+
+ public static event PlayerEvent OnPlayerJoin;
+ public static event PlayerDisconnectEvent OnPlayerLeave;
+ public static event PlayerVehicleEvent OnPlayerExitVehicle;
+
+ private static readonly ILogger logger = LogManager.GetLogger();
+
+ public EventHandler(IAPI api)
+ {
+ this.api = api;
+ }
+
+ public void RegisterClientEvent(string eventName, ClientEvent clientEvent)
+ {
+ if (eventName.IsNullOrEmpty())
+ {
+ logger.LogWarning("'eventName' is null");
+ return;
+ }
+
+ if (clientEvents.ContainsKey(eventName))
+ {
+ logger.LogError("Client event '{EventName}' is already registered", eventName);
+ return;
+ }
+
+ clientEvents[eventName] = clientEvent;
+ }
+
+ public void HandleEvent(IPlayer player, List