diff --git a/src/Prima.Core.Server/Data/Internal/TimerDataObject.cs b/src/Prima.Core.Server/Data/Internal/TimerDataObject.cs
new file mode 100644
index 0000000..da4852a
--- /dev/null
+++ b/src/Prima.Core.Server/Data/Internal/TimerDataObject.cs
@@ -0,0 +1,78 @@
+namespace Prima.Core.Server.Data.Internal;
+
+public class TimerDataObject : IDisposable
+{
+ private readonly object _lock = new object();
+ public string Name { get; set; }
+
+ public string Id { get; set; }
+
+ public double IntervalInMs { get; set; }
+
+ public Action Callback { get; set; }
+
+ public bool Repeat { get; set; }
+
+ public double RemainingTimeInMs = 0;
+
+ public double DelayInMs { get; set; }
+
+
+ public void DecrementRemainingTime(double deltaTime)
+ {
+ if (Monitor.TryEnter(_lock))
+ {
+ try
+ {
+ if (DelayInMs > 0)
+ {
+ DelayInMs -= deltaTime;
+ if (DelayInMs > 0)
+ {
+ return;
+ }
+ }
+
+ RemainingTimeInMs -= deltaTime;
+ }
+ finally
+ {
+ Monitor.Exit(_lock);
+ }
+ }
+ }
+
+ public void ResetRemainingTime()
+ {
+ if (Monitor.TryEnter(_lock))
+ {
+ try
+ {
+ RemainingTimeInMs = IntervalInMs;
+ }
+ finally
+ {
+ Monitor.Exit(_lock);
+ }
+ }
+ }
+
+
+ public override string ToString()
+ {
+ return $"Timer: {Name}, Id: {Id}, Interval: {IntervalInMs}, RemainingTime: {RemainingTimeInMs}, Repeat: {Repeat}";
+ }
+
+ public void Dispose()
+ {
+ Callback = null;
+ Name = null;
+ Id = null;
+ IntervalInMs = 0;
+ RemainingTimeInMs = 0;
+ Repeat = false;
+ DelayInMs = 0;
+
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Prima.Core.Server/Interfaces/Services/ITimerService.cs b/src/Prima.Core.Server/Interfaces/Services/ITimerService.cs
new file mode 100644
index 0000000..8b9b33d
--- /dev/null
+++ b/src/Prima.Core.Server/Interfaces/Services/ITimerService.cs
@@ -0,0 +1,12 @@
+using Orion.Core.Server.Interfaces.Services.Base;
+
+namespace Prima.Core.Server.Interfaces.Services;
+
+public interface ITimerService : IOrionService, IOrionStartService, IDisposable
+{
+ string RegisterTimer(string name, double intervalInMs, Action callback, double delayInMs = 0, bool repeat = false);
+
+ void UnregisterTimer(string timerId);
+
+ void UnregisterAllTimers();
+}
diff --git a/src/Prima.Core.Server/Modules/Scripts/FileScriptModule.cs b/src/Prima.Core.Server/Modules/Scripts/FileScriptModule.cs
new file mode 100644
index 0000000..1a1dfa4
--- /dev/null
+++ b/src/Prima.Core.Server/Modules/Scripts/FileScriptModule.cs
@@ -0,0 +1,61 @@
+using Orion.Core.Server.Attributes.Scripts;
+using Orion.Core.Server.Data.Directories;
+using Orion.Core.Server.Interfaces.Services.System;
+
+namespace Prima.Core.Server.Modules.Scripts;
+
+[ScriptModule("files")]
+public class FileScriptModule
+{
+ private readonly DirectoriesConfig _directoriesConfig;
+
+ private readonly IScriptEngineService _scriptEngineService;
+
+ public FileScriptModule(DirectoriesConfig directoriesConfig, IScriptEngineService scriptEngineService)
+ {
+ _directoriesConfig = directoriesConfig;
+ _scriptEngineService = scriptEngineService;
+ }
+
+
+ [ScriptFunction("Include a script file")]
+ public void IncludeScript(string fileName)
+ {
+ if (string.IsNullOrEmpty(fileName))
+ {
+ throw new ArgumentNullException(nameof(fileName), "File name cannot be null or empty");
+ }
+
+ var filePath = Path.Combine(_directoriesConfig.Root, fileName);
+
+ if (!File.Exists(filePath))
+ {
+ throw new FileNotFoundException($"Script file '{fileName}' not found in the scripts directory.");
+ }
+
+ _scriptEngineService.ExecuteScriptFile(filePath);
+ }
+
+ [ScriptFunction("Include all script files in a directory")]
+ public void IncludeScripts(string directory)
+ {
+ if (string.IsNullOrEmpty(directory))
+ {
+ throw new ArgumentNullException(nameof(directory), "Directory name cannot be null or empty");
+ }
+
+ var directoryPath = Path.Combine(_directoriesConfig.Root, directory);
+
+ if (!Directory.Exists(directoryPath))
+ {
+ throw new DirectoryNotFoundException($"Directory '{directory}' not found in the scripts directory.");
+ }
+
+ var scriptFiles = Directory.GetFiles(directoryPath, "*.js");
+
+ foreach (var scriptFile in scriptFiles)
+ {
+ _scriptEngineService.ExecuteScriptFile(scriptFile);
+ }
+ }
+}
diff --git a/src/Prima.Core.Server/Prima.Core.Server.csproj b/src/Prima.Core.Server/Prima.Core.Server.csproj
index 1e4df9c..f64a49a 100644
--- a/src/Prima.Core.Server/Prima.Core.Server.csproj
+++ b/src/Prima.Core.Server/Prima.Core.Server.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/src/Prima.Network/Packets/ClientVersionReq.cs b/src/Prima.Network/Packets/ClientVersionReq.cs
new file mode 100644
index 0000000..2558876
--- /dev/null
+++ b/src/Prima.Network/Packets/ClientVersionReq.cs
@@ -0,0 +1,15 @@
+using Prima.Network.Packets.Base;
+
+namespace Prima.Network.Packets;
+
+public class ClientVersionReq : BaseUoNetworkPacket
+{
+ public ClientVersionReq() : base(0xBD, 3)
+ {
+ }
+
+ public override Span Write()
+ {
+ return new byte[] { 0x00, 0x03 };
+ }
+}
diff --git a/src/Prima.Network/Prima.Network.csproj b/src/Prima.Network/Prima.Network.csproj
index 1ca1052..3b67775 100644
--- a/src/Prima.Network/Prima.Network.csproj
+++ b/src/Prima.Network/Prima.Network.csproj
@@ -9,9 +9,9 @@
-
-
-
+
+
+
diff --git a/src/Prima.Server/Handlers/CharacterCreationHandler.cs b/src/Prima.Server/Handlers/CharacterCreationHandler.cs
index aaff911..4537955 100644
--- a/src/Prima.Server/Handlers/CharacterCreationHandler.cs
+++ b/src/Prima.Server/Handlers/CharacterCreationHandler.cs
@@ -1,15 +1,29 @@
+using Orion.Core.Server.Interfaces.Services.System;
using Prima.Core.Server.Data.Session;
using Prima.Core.Server.Handlers.Base;
using Prima.Core.Server.Interfaces.Listeners;
using Prima.Core.Server.Interfaces.Services;
+using Prima.Network.Packets;
+using Prima.Server.Modules.Scripts;
+using Prima.UOData.Data.EventData;
+using Prima.UOData.Interfaces.Services;
using Prima.UOData.Packets;
namespace Prima.Server.Handlers;
public class CharacterCreationHandler : BasePacketListenerHandler, INetworkPacketListener
{
- public CharacterCreationHandler(ILogger logger, INetworkService networkService, IServiceProvider serviceProvider) : base(logger, networkService, serviceProvider)
+ private readonly IScriptEngineService _scriptEngineService;
+
+ private readonly IMapService _mapService;
+
+ public CharacterCreationHandler(
+ ILogger logger, INetworkService networkService, IServiceProvider serviceProvider,
+ IScriptEngineService scriptEngineService, IMapService mapService
+ ) : base(logger, networkService, serviceProvider)
{
+ _scriptEngineService = scriptEngineService;
+ _mapService = mapService;
}
protected override void RegisterHandlers()
@@ -19,6 +33,34 @@ protected override void RegisterHandlers()
public async Task OnPacketReceived(NetworkSession session, CharacterCreation packet)
{
+ // TODO: persist character creation data
+
+ TriggerCharacterCreatedEvent(packet);
+
+ await session.SendPacketAsync(new ClientVersionReq());
+ }
+
+ private void TriggerCharacterCreatedEvent(CharacterCreation packet)
+ {
+ var eventArgs = new CharacterCreatedEventArgs(
+ packet.Name,
+ packet.IsFemale,
+ packet.Hue,
+ packet.Int,
+ packet.Str,
+ packet.Dex,
+ _mapService.GetAvailableStartingCities()[packet.StartingLocation],
+ packet.Skills,
+ packet.ShirtColor,
+ packet.PantsColor,
+ packet.HairStyle,
+ packet.HairColor,
+ packet.FacialHair,
+ packet.FacialHairColor,
+ packet.Profession,
+ packet.Race
+ );
+ _scriptEngineService.ExecuteCallback(nameof(EventScriptModule.OnCharacterCreated), eventArgs);
}
}
diff --git a/src/Prima.Server/Modules/Scripts/EventScriptModule.cs b/src/Prima.Server/Modules/Scripts/EventScriptModule.cs
index b6a1957..d2447fe 100644
--- a/src/Prima.Server/Modules/Scripts/EventScriptModule.cs
+++ b/src/Prima.Server/Modules/Scripts/EventScriptModule.cs
@@ -1,6 +1,7 @@
using Orion.Core.Server.Attributes.Scripts;
using Orion.Core.Server.Interfaces.Services.System;
using Prima.Core.Server.Contexts;
+using Prima.UOData.Data.EventData;
namespace Prima.Server.Modules.Scripts;
@@ -50,4 +51,25 @@ public void OnUserLogin(Action action)
}
);
}
+
+ [ScriptFunction("Register a callback when user creates a character")]
+ public void OnCharacterCreated(Action action)
+ {
+ _scriptEngineService.AddCallback(
+ nameof(OnCharacterCreated),
+ context =>
+ {
+ if (context == null)
+ {
+ throw new ArgumentNullException(nameof(context), "Context cannot be null");
+ return;
+ }
+
+ if (context[0] is CharacterCreatedEventArgs characterCreatedEventArgs)
+ {
+ action(characterCreatedEventArgs);
+ }
+ }
+ );
+ }
}
diff --git a/src/Prima.Server/Modules/Scripts/TimerScriptModule.cs b/src/Prima.Server/Modules/Scripts/TimerScriptModule.cs
new file mode 100644
index 0000000..b6b14fe
--- /dev/null
+++ b/src/Prima.Server/Modules/Scripts/TimerScriptModule.cs
@@ -0,0 +1,69 @@
+using Orion.Core.Server.Attributes.Scripts;
+using Prima.Core.Server.Interfaces.Services;
+
+namespace Prima.Server.Modules.Scripts;
+
+[ScriptModule("timers")]
+public class TimerScriptModule
+{
+ private readonly ITimerService _timerService;
+
+ public TimerScriptModule(ITimerService timerService)
+ {
+ _timerService = timerService;
+ }
+
+
+ [ScriptFunction("Register a timer")]
+ public string Register(
+ string name, int intervalInSeconds, Action callback, int delayInSeconds = 0, bool isRepeat = false
+ )
+ {
+ if (string.IsNullOrEmpty(name))
+ {
+ throw new ArgumentNullException(nameof(name), "Timer name cannot be null or empty");
+ }
+
+ if (intervalInSeconds <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(intervalInSeconds), "Interval must be greater than zero");
+ }
+
+ if (callback == null)
+ {
+ throw new ArgumentNullException(nameof(callback), "Callback cannot be null");
+ }
+
+ return _timerService.RegisterTimer(
+ name,
+ TimeSpan.FromSeconds(intervalInSeconds).TotalMilliseconds,
+ callback,
+ TimeSpan.FromSeconds(delayInSeconds).TotalMilliseconds,
+ isRepeat
+ );
+ }
+
+ [ScriptFunction("Register a timer that repeats")]
+ public string Repeated(string name, int intervalInSeconds, Action callback, int delayInSeconds = 0)
+ {
+ return Register(name, intervalInSeconds, callback, delayInSeconds, true);
+ }
+
+
+ [ScriptFunction("Register a timer that runs once")]
+ public string OneShot(string name, int intervalInSeconds, Action callback, int delayInSeconds = 0)
+ {
+ return Register(name, intervalInSeconds, callback, delayInSeconds, false);
+ }
+
+ [ScriptFunction("Unregister a timer")]
+ public void Unregister(string timerId)
+ {
+ if (string.IsNullOrEmpty(timerId))
+ {
+ throw new ArgumentNullException(nameof(timerId), "Timer ID cannot be null or empty");
+ }
+
+ _timerService.UnregisterTimer(timerId);
+ }
+}
diff --git a/src/Prima.Server/Program.cs b/src/Prima.Server/Program.cs
index a633731..f3d7394 100644
--- a/src/Prima.Server/Program.cs
+++ b/src/Prima.Server/Program.cs
@@ -17,6 +17,7 @@
using Prima.Core.Server.Data.Options;
using Prima.Core.Server.Interfaces.Services;
using Prima.Core.Server.Modules.Container;
+using Prima.Core.Server.Modules.Scripts;
using Prima.Core.Server.Types;
using Prima.Network.Modules;
using Prima.Server.Handlers;
@@ -103,6 +104,7 @@ static async Task Main(string[] args)
.AddService()
.AddService()
.AddService()
+ .AddService()
.AddService()
;
@@ -110,6 +112,8 @@ static async Task Main(string[] args)
.AddScriptModule()
.AddScriptModule()
.AddScriptModule()
+ .AddScriptModule()
+ .AddScriptModule()
.AddScriptModule();
builder.Services
diff --git a/src/Prima.Server/Services/NetworkService.cs b/src/Prima.Server/Services/NetworkService.cs
index a2dda0c..759e372 100644
--- a/src/Prima.Server/Services/NetworkService.cs
+++ b/src/Prima.Server/Services/NetworkService.cs
@@ -324,7 +324,7 @@ private async Task HandleIncomingMessages(NetworkMessageData data)
public async Task StartAsync(CancellationToken cancellationToken = default)
{
var loginServers =
- GetListeningAddresses(IPEndPoint.Parse(IPAddress.Any.ToString() + _serverConfig.TcpServer.LoginPort)).ToList();
+ GetListeningAddresses(IPEndPoint.Parse("0.0.0.0:" + _serverConfig.TcpServer.LoginPort)).ToList();
foreach (var loginServer in loginServers)
{
@@ -380,7 +380,11 @@ public async Task StopAsync(CancellationToken cancellationToken = default)
public void RegisterPacketListener(INetworkPacketListener listener) where TPacket : IUoNetworkPacket, new()
{
var packet = new TPacket();
- _logger.LogInformation("Registering packet listener for {PacketType}", "0x" + packet.OpCode.ToString("X2"));
+ _logger.LogInformation(
+ "Registering packet listener for {PacketType} (PacketType: {Type})",
+ "0x" + packet.OpCode.ToString("X2"),
+ packet.GetType().Name
+ );
if (!_listeners.TryGetValue(packet.OpCode, out var packetListeners))
{
diff --git a/src/Prima.Server/Services/TimerService.cs b/src/Prima.Server/Services/TimerService.cs
new file mode 100644
index 0000000..7b79b97
--- /dev/null
+++ b/src/Prima.Server/Services/TimerService.cs
@@ -0,0 +1,148 @@
+using System.Collections.Concurrent;
+using Orion.Foundations.Pool;
+using Prima.Core.Server.Data.Internal;
+using Prima.Core.Server.Interfaces.Services;
+
+namespace Prima.Server.Services;
+
+public class TimerService : ITimerService
+{
+ private readonly ILogger _logger;
+ private readonly IEventLoopService _eventLoopService;
+
+ private readonly ObjectPool _timerDataPool = new(5);
+
+ private readonly SemaphoreSlim _timerSemaphore = new(1, 1);
+ private readonly BlockingCollection _timers = new();
+
+ public TimerService(ILogger logger, IEventLoopService eventLoopService)
+ {
+ _logger = logger;
+ _eventLoopService = eventLoopService;
+ }
+
+ private void EventLoopServiceOnOnTick(double tickDurationMs)
+ {
+ _timerSemaphore.Wait();
+
+ foreach (var timer in _timers)
+ {
+ timer.DecrementRemainingTime(tickDurationMs);
+
+ if (timer.RemainingTimeInMs <= 0)
+ {
+ try
+ {
+ timer.Callback?.Invoke();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error executing timer callback for {TimerId}", timer.Id);
+ }
+
+ if (timer.Repeat)
+ {
+ timer.ResetRemainingTime();
+ }
+ else
+ {
+ _timers.TryTake(out var _);
+ _logger.LogInformation("Unregistering timer: {TimerId}", timer.Id);
+ }
+ }
+ }
+
+ _timerSemaphore.Release();
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken = default)
+ {
+ _eventLoopService.OnTick += EventLoopServiceOnOnTick;
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken = default)
+ {
+ return Task.CompletedTask;
+ }
+
+ public string RegisterTimer(string name, double intervalInMs, Action callback, double delayInMs = 0, bool repeat = false)
+ {
+ var existingTimer = _timers.FirstOrDefault(t => t.Name == name);
+
+ if (existingTimer != null)
+ {
+ _logger.LogWarning("Timer with name {Name} already exists. Unregistering it.", name);
+ UnregisterTimer(existingTimer.Id);
+ }
+
+ _timerSemaphore.Wait();
+
+ var timerId = Guid.NewGuid().ToString();
+ var timer = _timerDataPool.Get();
+
+ timer.Name = name;
+ timer.Id = timerId;
+ timer.IntervalInMs = intervalInMs;
+ timer.Callback = callback;
+ timer.Repeat = repeat;
+ timer.RemainingTimeInMs = intervalInMs;
+ timer.DelayInMs = delayInMs;
+
+
+ _timers.Add(timer);
+
+ _timerSemaphore.Release();
+
+ _logger.LogDebug(
+ "Registering timer: {TimerId}, Interval: {IntervalInSeconds} ms, Repeat: {Repeat}",
+ timerId,
+ intervalInMs,
+ repeat
+ );
+
+ return timerId;
+ }
+
+ public void UnregisterTimer(string timerId)
+ {
+ _timerSemaphore.Wait();
+
+ var timer = _timers.FirstOrDefault(t => t.Id == timerId);
+
+ if (timer != null)
+ {
+ _timers.TryTake(out timer);
+ _logger.LogInformation("Unregistering timer: {TimerId}", timer.Id);
+ _timerDataPool.Return(timer);
+ }
+ else
+ {
+ _logger.LogWarning("Timer with ID {TimerId} not found", timerId);
+ }
+
+ _timerSemaphore.Release();
+ }
+
+ public void UnregisterAllTimers()
+ {
+ _timerSemaphore.Wait();
+
+ while (_timers.TryTake(out var timer))
+ {
+ _logger.LogInformation("Unregistering timer: {TimerId}", timer.Id);
+ }
+
+ _timerSemaphore.Release();
+ }
+
+ public void Dispose()
+ {
+ _timerSemaphore.Dispose();
+ _timers.Dispose();
+
+ _eventLoopService.OnTick -= EventLoopServiceOnOnTick;
+
+ GC.SuppressFinalize(this);
+ }
+}
diff --git a/src/Prima.UOData/Context/UOContext.cs b/src/Prima.UOData/Context/UOContext.cs
index 185bf0c..8092946 100644
--- a/src/Prima.UOData/Context/UOContext.cs
+++ b/src/Prima.UOData/Context/UOContext.cs
@@ -1,12 +1,13 @@
+using System.Runtime.CompilerServices;
using Prima.Core.Server.Data.Uo;
using Prima.Core.Server.Types.Uo;
using Prima.UOData.Data;
+using Prima.UOData.Types;
namespace Prima.UOData.Context;
public static class UOContext
{
-
public static int SlotLimit { get; set; } = 5;
public static ClientVersion ClientVersion { get; set; }
@@ -14,4 +15,26 @@ public static class UOContext
public static Expansion Expansion { get; set; }
public static ExpansionInfo ExpansionInfo { get; set; }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool HasProtocolChanges(ProtocolChanges changes) => (ClientVersion.ProtocolChanges & changes) != 0;
+
+ public static bool NewSpellbook => HasProtocolChanges(ProtocolChanges.NewSpellbook);
+ public static bool DamagePacket => HasProtocolChanges(ProtocolChanges.DamagePacket);
+ public static bool BuffIcon => HasProtocolChanges(ProtocolChanges.BuffIcon);
+ public static bool NewHaven => HasProtocolChanges(ProtocolChanges.NewHaven);
+ public static bool ContainerGridLines => HasProtocolChanges(ProtocolChanges.ContainerGridLines);
+ public static bool ExtendedSupportedFeatures => HasProtocolChanges(ProtocolChanges.ExtendedSupportedFeatures);
+ public static bool StygianAbyss => HasProtocolChanges(ProtocolChanges.StygianAbyss);
+ public static bool HighSeas => HasProtocolChanges(ProtocolChanges.HighSeas);
+ public static bool NewCharacterList => HasProtocolChanges(ProtocolChanges.NewCharacterList);
+ public static bool NewCharacterCreation => HasProtocolChanges(ProtocolChanges.NewCharacterCreation);
+ public static bool ExtendedStatus => HasProtocolChanges(ProtocolChanges.ExtendedStatus);
+ public static bool NewMobileIncoming => HasProtocolChanges(ProtocolChanges.NewMobileIncoming);
+ public static bool NewSecureTrading => HasProtocolChanges(ProtocolChanges.NewSecureTrading);
+
+ //public static bool IsUOTDClient => HasFlag(ClientFlags.UOTD) || ClientVersion?.Type == ClientType.UOTD;
+ public static bool IsKRClient => ClientVersion?.Type == ClientType.KR;
+ public static bool IsSAClient => ClientVersion?.Type == ClientType.SA;
+ public static bool IsEnhancedClient => ClientVersion?.Type is ClientType.KR or ClientType.SA;
}
diff --git a/src/Prima.UOData/Data/EventData/CharacterCreatedEventArgs.cs b/src/Prima.UOData/Data/EventData/CharacterCreatedEventArgs.cs
new file mode 100644
index 0000000..cc1e15d
--- /dev/null
+++ b/src/Prima.UOData/Data/EventData/CharacterCreatedEventArgs.cs
@@ -0,0 +1,61 @@
+using Prima.UOData.Data.Map;
+using Prima.UOData.Types;
+
+namespace Prima.UOData.Data.EventData;
+
+public class CharacterCreatedEventArgs(
+ // NetState state, IAccount a,
+ string name,
+ bool female,
+ int hue,
+ int inte,
+ int str,
+ int dex,
+ CityInfo city,
+ Dictionary skills,
+ int shirtHue,
+ int pantsHue,
+ int hairId,
+ int hairHue,
+ int beardId,
+ int beardHue,
+ ProfessionInfo profession,
+ Race race
+)
+{
+ // public NetState State { get; } = state;
+ //
+ // public IAccount Account { get; } = a;
+ //
+ // public Mobile Mobile { get; set; }
+
+ public string Name { get; } = name;
+
+ public bool Female { get; } = female;
+
+ public int Hue { get; } = hue;
+
+ public int Inte { get; } = inte;
+ public int Str { get; } = str;
+ public int Dex { get; } = dex;
+
+ public CityInfo City { get; } = city;
+
+ public Dictionary Skills { get; } = skills;
+
+ public int ShirtHue { get; } = shirtHue;
+
+ public int PantsHue { get; } = pantsHue;
+
+ public int HairID { get; } = hairId;
+
+ public int HairHue { get; } = hairHue;
+
+ public int BeardID { get; } = beardId;
+
+ public int BeardHue { get; } = beardHue;
+
+ public ProfessionInfo Profession { get; set; } = profession;
+
+ public Race Race { get; } = race;
+}
diff --git a/src/Prima.UOData/Data/Race.cs b/src/Prima.UOData/Data/Race.cs
new file mode 100644
index 0000000..2bdace6
--- /dev/null
+++ b/src/Prima.UOData/Data/Race.cs
@@ -0,0 +1,158 @@
+using System.Runtime.CompilerServices;
+using Orion.Foundations.Extensions;
+using Prima.Core.Server.Types.Uo;
+
+namespace Prima.UOData.Data;
+
+public abstract class Race : ISpanParsable
+{
+ protected Race(
+ int raceID, int raceIndex, string name, string pluralName, int maleBody, int femaleBody,
+ int maleGhostBody, int femaleGhostBody, Expansion requiredExpansion
+ )
+ {
+ RaceID = raceID;
+ RaceIndex = raceIndex;
+ RaceFlag = 1 << raceIndex;
+
+ Name = name;
+
+ MaleBody = maleBody;
+ FemaleBody = femaleBody;
+ MaleGhostBody = maleGhostBody;
+ FemaleGhostBody = femaleGhostBody;
+
+ RequiredExpansion = requiredExpansion;
+ PluralName = pluralName;
+ }
+
+ public static Race[] Races { get; } = new Race[0x100];
+
+ public static Race DefaultRace => Races[0];
+ public static Race Human => Races[0];
+ public static Race Elf => Races[1];
+ public static Race Gargoyle => Races[2];
+
+ public static List AllRaces { get; } = new();
+
+ public const int AllowAllRaces = 0x7; // Race.Human.RaceFlag | Race.Elf.RaceFlag | Race.Gargoyle.RaceFlag
+ public const int AllowHumanOrElves = 0x3; // Race.Human.RaceFlag | Race.Elf.RaceFlag
+ public const int AllowElvesOnly = 0x2; // Race.Elf.RaceFlag
+ public const int AllowGargoylesOnly = 0x4; // Race.Gargoyle.RaceFlag
+
+ public Expansion RequiredExpansion { get; }
+
+ public int MaleBody { get; }
+
+ public int MaleGhostBody { get; }
+
+ public int FemaleBody { get; }
+
+ public int FemaleGhostBody { get; }
+
+ public int RaceID { get; }
+
+ public int RaceIndex { get; }
+
+ public int RaceFlag { get; }
+
+ public string Name { get; set; }
+
+ public string PluralName { get; set; }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool IsAllowedRace(Race race, int allowedRaceFlags) => (allowedRaceFlags & race.RaceFlag) != 0;
+
+ public override string ToString() => Name;
+
+ // public virtual bool ValidateHair(Mobile m, int itemID) => ValidateHair(m.Female, itemID);
+
+ public abstract bool ValidateHair(bool female, int itemID);
+
+ //public virtual int RandomHair(Mobile m) => RandomHair(m.Female);
+
+ public abstract int RandomHair(bool female);
+
+ //public virtual bool ValidateFacialHair(Mobile m, int itemID) => ValidateFacialHair(m.Female, itemID);
+
+ public abstract bool ValidateFacialHair(bool female, int itemID);
+
+ // public virtual int RandomFacialHair(Mobile m) => RandomFacialHair(m.Female);
+
+ public abstract int RandomFacialHair(bool female); // For the *ahem* bearded ladies
+
+ public abstract int ClipSkinHue(int hue);
+ public abstract int RandomSkinHue();
+
+ public abstract int ClipHairHue(int hue);
+ public abstract int RandomHairHue();
+
+ // public virtual int Body(Mobile m) => m.Alive ? AliveBody(m.Female) : GhostBody(m.Female);
+
+ // public virtual int AliveBody(Mobile m) => AliveBody(m.Female);
+
+ public virtual int AliveBody(bool female) => female ? FemaleBody : MaleBody;
+
+ // public virtual int GhostBody(Mobile m) => GhostBody(m.Female);
+
+ public virtual int GhostBody(bool female) => female ? FemaleGhostBody : MaleGhostBody;
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Race Parse(string s) => Parse(s, null);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static Race Parse(string s, IFormatProvider provider) => Parse(s.AsSpan(), provider);
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public static bool TryParse(string s, IFormatProvider provider, out Race result) =>
+ TryParse(s.AsSpan(), provider, out result);
+
+ public static Race Parse(ReadOnlySpan s, IFormatProvider provider)
+ {
+ if (int.TryParse(s, out var index) && index >= 0 && index < Races.Length)
+ {
+ var race = Races[index];
+ if (race != null)
+ {
+ return race;
+ }
+ }
+
+ s = s.Trim();
+
+ for (var i = 0; i < Races.Length; ++i)
+ {
+ var race = Races[i];
+ if (s.InsensitiveEquals(race.Name) || s.InsensitiveEquals(race.PluralName))
+ {
+ return race;
+ }
+ }
+
+ throw new FormatException($"The input string '{s}' was not in a correct format.");
+ }
+
+ public static bool TryParse(ReadOnlySpan s, IFormatProvider provider, out Race result)
+ {
+ if (int.TryParse(s, out var index) && index >= 0 && index < Races.Length)
+ {
+ result = Races[index];
+ return result != null;
+ }
+
+ s = s.Trim();
+
+ for (var i = 0; i < Races.Length; ++i)
+ {
+ var race = Races[i];
+ if (s.InsensitiveEquals(race.Name) || s.InsensitiveEquals(race.PluralName))
+ {
+ result = race;
+ return true;
+ }
+ }
+
+ result = default;
+ return false;
+ }
+}
diff --git a/src/Prima.UOData/Data/RaceDefinitions.cs b/src/Prima.UOData/Data/RaceDefinitions.cs
new file mode 100644
index 0000000..e4fec3f
--- /dev/null
+++ b/src/Prima.UOData/Data/RaceDefinitions.cs
@@ -0,0 +1,318 @@
+using Orion.Foundations.Extensions;
+using Orion.Foundations.Utils;
+using Prima.Core.Server.Types.Uo;
+using Prima.UOData.Utils;
+
+namespace Prima.UOData.Data;
+
+public static class RaceDefinitions
+{
+ public static void Configure()
+ {
+ /* Here we configure all races. Some notes:
+ *
+ * 1) The first 32 races are reserved for core use.
+ * 2) Race 0x7F is reserved for core use.
+ * 3) Race 0xFF is reserved for core use.
+ * 4) Changing or removing any predefined races may cause server instability.
+ */
+
+ RegisterRace(new Human(0, 0));
+ RegisterRace(new Elf(1, 1));
+ RegisterRace(new Gargoyle(2, 2));
+ }
+
+ public static void RegisterRace(Race race)
+ {
+ Race.Races[race.RaceIndex] = race;
+ Race.AllRaces.Add(race);
+ }
+
+ private class Human : Race
+ {
+ public Human(int raceID, int raceIndex)
+ : base(raceID, raceIndex, "Human", "Humans", 400, 401, 402, 403, Expansion.None)
+ {
+ }
+
+ public override bool ValidateHair(bool female, int itemID)
+ {
+ if (itemID == 0)
+ {
+ return true;
+ }
+
+ if (female && itemID == 0x2048 || !female && itemID == 0x2046)
+ {
+ return false; // Buns & Receding Hair
+ }
+
+ if (itemID is >= 0x203B and <= 0x203D)
+ {
+ return true;
+ }
+
+ if (itemID is >= 0x2044 and <= 0x204A)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public override int RandomHair(bool female) // Random hair doesn't include baldness
+ {
+ return RandomUtils.Random(9) switch
+ {
+ 0 => 0x203B, // Short
+ 1 => 0x203C, // Long
+ 2 => 0x203D, // Pony Tail
+ 3 => 0x2044, // Mohawk
+ 4 => 0x2045, // Pageboy
+ 5 => 0x2047, // Afro
+ 6 => 0x2049, // Pig tails
+ 7 => 0x204A, // Krisna
+ _ => female ? 0x2046 : 0x2048
+ };
+ }
+
+ public override bool ValidateFacialHair(bool female, int itemID)
+ {
+ if (itemID == 0)
+ {
+ return true;
+ }
+
+ if (female)
+ {
+ return false;
+ }
+
+ if (itemID is >= 0x203E and <= 0x2041)
+ {
+ return true;
+ }
+
+ if (itemID is >= 0x204B and <= 0x204D)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public override int RandomFacialHair(bool female)
+ {
+ if (female)
+ {
+ return 0;
+ }
+
+ var rand = RandomUtils.Random(7);
+
+ return (rand < 4 ? 0x203E : 0x2047) + rand;
+ }
+
+ public override int ClipSkinHue(int hue)
+ {
+ return hue switch
+ {
+ < 1002 => 1002,
+ > 1058 => 1058,
+ _ => hue
+ };
+ }
+
+ public override int RandomSkinHue() => RandomUtils.Random(1002, 57) | 0x8000;
+
+ public override int ClipHairHue(int hue)
+ {
+ return hue switch
+ {
+ < 1102 => 1102,
+ > 1149 => 1149,
+ _ => hue
+ };
+ }
+
+ public override int RandomHairHue() => RandomUtils.Random(1102, 48);
+ }
+
+ private class Elf : Race
+ {
+ private static readonly int[] m_SkinHues =
+ {
+ 0x0BF, 0x24D, 0x24E, 0x24F, 0x353, 0x361, 0x367, 0x374,
+ 0x375, 0x376, 0x381, 0x382, 0x383, 0x384, 0x385, 0x389,
+ 0x3DE, 0x3E5, 0x3E6, 0x3E8, 0x3E9, 0x430, 0x4A7, 0x4DE,
+ 0x51D, 0x53F, 0x579, 0x76B, 0x76C, 0x76D, 0x835, 0x903
+ };
+
+ private static readonly int[] m_HairHues =
+ {
+ 0x034, 0x035, 0x036, 0x037, 0x038, 0x039, 0x058, 0x08E,
+ 0x08F, 0x090, 0x091, 0x092, 0x101, 0x159, 0x15A, 0x15B,
+ 0x15C, 0x15D, 0x15E, 0x128, 0x12F, 0x1BD, 0x1E4, 0x1F3,
+ 0x207, 0x211, 0x239, 0x251, 0x26C, 0x2C3, 0x2C9, 0x31D,
+ 0x31E, 0x31F, 0x320, 0x321, 0x322, 0x323, 0x324, 0x325,
+ 0x326, 0x369, 0x386, 0x387, 0x388, 0x389, 0x38A, 0x59D,
+ 0x6B8, 0x725, 0x853
+ };
+
+ public Elf(int raceID, int raceIndex)
+ : base(raceID, raceIndex, "Elf", "Elves", 605, 606, 607, 608, Expansion.ML)
+ {
+ }
+
+ public override bool ValidateHair(bool female, int itemID)
+ {
+ if (itemID == 0)
+ {
+ return true;
+ }
+
+ if (female && itemID is 0x2FCD or 0x2FBF || !female && itemID is 0x2FCC or 0x2FD0)
+ {
+ return false;
+ }
+
+ if (itemID is >= 0x2FBF and <= 0x2FC2)
+ {
+ return true;
+ }
+
+ if (itemID is >= 0x2FCC and <= 0x2FD1)
+ {
+ return true;
+ }
+
+ return false;
+ }
+
+ public override int RandomHair(bool female) // Random hair doesn't include baldness
+ {
+ return RandomUtils.Random(8) switch
+ {
+ 0 => 0x2FC0, // Long Feather
+ 1 => 0x2FC1, // Short
+ 2 => 0x2FC2, // Mullet
+ 3 => 0x2FCE, // Knob
+ 4 => 0x2FCF, // Braided
+ 5 => 0x2FD1, // Spiked
+ 6 => female ? 0x2FCC : 0x2FBF, // Flower or Mid-long
+ _ => female ? 0x2FD0 : 0x2FCD
+ };
+ }
+
+ public override bool ValidateFacialHair(bool female, int itemID) => itemID == 0;
+
+ public override int RandomFacialHair(bool female) => 0;
+
+ public override int ClipSkinHue(int hue)
+ {
+ for (var i = 0; i < m_SkinHues.Length; i++)
+ {
+ if (m_SkinHues[i] == hue)
+ {
+ return hue;
+ }
+ }
+
+ return m_SkinHues[0];
+ }
+
+ public override int RandomSkinHue() => m_SkinHues.RandomElement() | 0x8000;
+
+ public override int ClipHairHue(int hue)
+ {
+ for (var i = 0; i < m_HairHues.Length; i++)
+ {
+ if (m_HairHues[i] == hue)
+ {
+ return hue;
+ }
+ }
+
+ return m_HairHues[0];
+ }
+
+ public override int RandomHairHue() => m_HairHues.RandomElement();
+ }
+
+ private class Gargoyle : Race
+ {
+ private static readonly int[] m_HornHues =
+ {
+ 0x709, 0x70B, 0x70D, 0x70F, 0x711, 0x763,
+ 0x765, 0x768, 0x76B, 0x6F3, 0x6F1, 0x6EF,
+ 0x6E4, 0x6E2, 0x6E0, 0x709, 0x70B, 0x70D
+ };
+
+ public Gargoyle(int raceID, int raceIndex)
+ : base(raceID, raceIndex, "Gargoyle", "Gargoyles", 666, 667, 402, 403, Expansion.SA)
+ {
+ }
+
+ public override bool ValidateHair(bool female, int itemID)
+ {
+ if (female == false)
+ {
+ return itemID is >= 0x4258 and <= 0x425F;
+ }
+
+ return itemID is 0x4261 or 0x4262 or >= 0x4273 and <= 0x4275 or 0x42B0 or 0x42B1 or 0x42AA or 0x42AB;
+ }
+
+ public override int RandomHair(bool female)
+ {
+ if (RandomUtils.Random(9) == 0)
+ {
+ return 0;
+ }
+
+ if (!female)
+ {
+ return 0x4258 + RandomUtils.Random(8);
+ }
+
+ return RandomUtils.Random(9) switch
+ {
+ 0 => 0x4261,
+ 1 => 0x4262,
+ 2 => 0x4273,
+ 3 => 0x4274,
+ 4 => 0x4275,
+ 5 => 0x42B0,
+ 6 => 0x42B1,
+ 7 => 0x42AA,
+ 8 => 0x42AB,
+ _ => 0
+ };
+ }
+
+ public override bool ValidateFacialHair(bool female, int itemID) =>
+ !female && itemID is >= 0x42AD and <= 0x42B0;
+
+ public override int RandomFacialHair(bool female) =>
+ female ? 0 : Utility.RandomList(0, 0x42AD, 0x42AE, 0x42AF, 0x42B0);
+
+ public override int ClipSkinHue(int hue) => hue;
+
+ public override int RandomSkinHue() => RandomUtils.Random(1755, 25) | 0x8000;
+
+ public override int ClipHairHue(int hue)
+ {
+ for (var i = 0; i < m_HornHues.Length; i++)
+ {
+ if (m_HornHues[i] == hue)
+ {
+ return hue;
+ }
+ }
+
+ return m_HornHues[0];
+ }
+
+ public override int RandomHairHue() => m_HornHues.RandomElement();
+ }
+}
diff --git a/src/Prima.UOData/Packets/CharacterCreation.cs b/src/Prima.UOData/Packets/CharacterCreation.cs
index 16a4607..bc97ae2 100644
--- a/src/Prima.UOData/Packets/CharacterCreation.cs
+++ b/src/Prima.UOData/Packets/CharacterCreation.cs
@@ -1,6 +1,6 @@
using Orion.Foundations.Spans;
using Prima.Network.Packets.Base;
-
+using Prima.UOData.Context;
using Prima.UOData.Data;
using Prima.UOData.Types;
@@ -27,6 +27,10 @@ public class CharacterCreation : BaseUoNetworkPacket
public int Int { get; set; }
+ public bool IsFemale { get; set; }
+
+ public int Hue { get; set; }
+
public short HairStyle { get; set; }
public short HairColor { get; set; }
@@ -37,6 +41,7 @@ public class CharacterCreation : BaseUoNetworkPacket
public short StartingLocation { get; set; }
+ public Race Race { get; set; }
public short ShirtColor { get; set; }
@@ -64,9 +69,15 @@ public override void Read(SpanReader reader)
LoginCount = reader.ReadInt32();
Profession = ProfessionInfo.Professions[reader.ReadByte()];
- reader.Read(new byte[15]);
+ reader.ReadBytes(15);
+
+ var genderRace = reader.ReadByte();
+ Sex = (SexType)genderRace;
- Sex = (SexType)reader.ReadByte();
+ IsFemale = genderRace % 2 != 0;
+
+ var raceID = UOContext.StygianAbyss ? (byte)(genderRace < 4 ? 0 : genderRace / 2 - 1) : (byte)(genderRace / 2);
+ Race = Race.Races[raceID] ?? Race.DefaultRace;
Str = reader.ReadByte();
Dex = reader.ReadByte();
@@ -80,6 +91,9 @@ public override void Read(SpanReader reader)
Skills.Add(skillName, skillValue);
}
+
+ Hue = reader.ReadUInt16();
+
HairStyle = reader.ReadInt16();
HairColor = reader.ReadInt16();
diff --git a/src/Prima.UOData/Services/ClientConfigurationService.cs b/src/Prima.UOData/Services/ClientConfigurationService.cs
index 95b7367..a045516 100644
--- a/src/Prima.UOData/Services/ClientConfigurationService.cs
+++ b/src/Prima.UOData/Services/ClientConfigurationService.cs
@@ -52,6 +52,8 @@ public async Task HandleAsync(ServerStartedEvent @event, CancellationToken cance
await GetExpansionAsync();
await LoadSkillInfoAsync();
await BuildProfessionsAsync();
+
+ RaceDefinitions.Configure();
}
private async Task GetExpansionAsync()
diff --git a/src/Prima.UOData/Utils/Utility.cs b/src/Prima.UOData/Utils/Utility.cs
index 30ef5c2..83d0117 100644
--- a/src/Prima.UOData/Utils/Utility.cs
+++ b/src/Prima.UOData/Utils/Utility.cs
@@ -19,8 +19,6 @@ public static partial class Utility
{
private static Dictionary _ipAddressTable;
- private static readonly Stack m_ConsoleColors = new();
-
public static void Separate(StringBuilder sb, string value, string separator)
{
if (sb.Length > 0)
@@ -281,4 +279,9 @@ public static void FixPoints(ref Point3D top, ref Point3D bottom)
(top.m_Z, bottom.m_Z) = (bottom.m_Z, top.m_Z);
}
}
+
+ public static T RandomList(params T[] array)
+ {
+ return array.ToList().RandomElement();
+ }
}