From c1df4830f0639612948f60749818a2555537efeb Mon Sep 17 00:00:00 2001 From: SabreDartStudios <108193207+SabreDartStudios@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:53:44 -0500 Subject: [PATCH 1/4] Convert CreateCharacter SP to parameterized query. --- .../Postgres/UsersRepository.cs | 2 +- src/OWSData/SQL/GenericQueries.cs | 213 ++++++++++++++++++ 2 files changed, 214 insertions(+), 1 deletion(-) diff --git a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs index 2e4e8bffb..dc079d98b 100644 --- a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs +++ b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs @@ -67,7 +67,7 @@ public async Task CreateCharacter(Guid customerGUID, Guid userS p.Add("@CharacterName", characterName); p.Add("@ClassName", className); - outputObject = await connection.QuerySingleAsync("select * from AddCharacter(@CustomerGUID,@UserSessionGUID,@CharacterName,@ClassName)", + outputObject = await Connection.QuerySingleAsync(GenericQueries.CreateCharacterSQL, p, commandType: CommandType.Text); } diff --git a/src/OWSData/SQL/GenericQueries.cs b/src/OWSData/SQL/GenericQueries.cs index d2ccd0021..67c005a8b 100644 --- a/src/OWSData/SQL/GenericQueries.cs +++ b/src/OWSData/SQL/GenericQueries.cs @@ -36,6 +36,219 @@ FROM Customers public static readonly string AddCharacterCustomDataField = @"INSERT INTO CustomCharacterData (CustomerGUID, CharacterID, CustomFieldName, FieldValue) VALUES (@CustomerGUID, @CharacterID, @CustomFieldName, @FieldValue)"; + public static readonly string CreateCharacterSQL = @"WITH input AS ( + SELECT @CustomerGUID::UUID AS customerguid, + @UserSessionGUID::UUID AS usersessionguid, + @CharacterName AS charactername, + @ClassName AS classname + ), + support_unicode AS ( + SELECT C.SupportUnicode AS supportunicode + FROM Customers C + JOIN input I ON C.CustomerGUID = I.customerguid + ), + user_data AS ( + SELECT US.UserGUID AS userguid + FROM UserSessions US + JOIN input I ON US.CustomerGUID = I.customerguid AND US.UserSessionGUID = I.usersessionguid + ), + class_data AS ( + SELECT C.ClassID AS classid + FROM Class C + JOIN input I ON C.CustomerGUID = I.customerguid AND C.ClassName = I.classname + ), + char_count AS ( + SELECT COUNT(*)::INT AS count + FROM Characters C + JOIN input I ON C.CustomerGUID = I.customerguid AND C.CharName = I.charactername + ), + normalized AS ( + SELECT REPLACE(REPLACE(REPLACE(TRIM(BOTH FROM I.charactername), ' ', '<>'), '><', ''), '<>', ' ') AS clean_name, + COALESCE(LENGTH(ARRAY_TO_STRING(REGEXP_MATCHES(REPLACE(REPLACE(REPLACE(TRIM(BOTH FROM I.charactername), ' ', '<>'), '><', ''), '<>', ' '), '[^a-zA-Z0-9 ]'), '')), 0) AS invalid_count + FROM input I + ), + errors AS ( + SELECT CASE + WHEN (SELECT invalid_count FROM normalized) > 0 + AND (SELECT supportunicode FROM support_unicode) = FALSE + THEN 'Character Name can only contain letters, numbers, and spaces' + WHEN (SELECT userguid FROM user_data) IS NULL + THEN 'Invalid User Session' + WHEN (SELECT classid FROM class_data) IS NULL + THEN 'Invalid Class Name' + WHEN (SELECT count FROM char_count) > 0 + THEN 'Invalid Character Name' + ELSE NULL + END AS errormessage + ), + class_inventory_count AS ( + SELECT COUNT(*)::INT AS count + FROM ClassInventory CI + JOIN input I ON CI.CustomerGUID = I.customerguid + JOIN class_data CD ON CI.ClassID = CD.classid + ), + insert_character AS ( + INSERT INTO Characters (CustomerGUID, ClassID, UserGUID, Email, CharName, MapName, X, Y, Z, Perception, + Acrobatics, Climb, Stealth, ServerIP, LastActivity, RX, RY, RZ, Spirit, Magic, + TeamNumber, Thirst, Hunger, Gold, Score, CharacterLevel, Gender, XP, HitDie, Wounds, + Size, Weight, MaxHealth, Health, HealthRegenRate, MaxMana, Mana, ManaRegenRate, + MaxEnergy, Energy, EnergyRegenRate, MaxFatigue, Fatigue, FatigueRegenRate, MaxStamina, + Stamina, StaminaRegenRate, MaxEndurance, Endurance, EnduranceRegenRate, Strength, + Dexterity, Constitution, Intellect, Wisdom, Charisma, Agility, Fortitude, Reflex, + Willpower, BaseAttack, BaseAttackBonus, AttackPower, AttackSpeed, CritChance, + CritMultiplier, Haste, SpellPower, SpellPenetration, Defense, Dodge, Parry, Avoidance, + Versatility, Multishot, Initiative, NaturalArmor, PhysicalArmor, BonusArmor, ForceArmor, + MagicArmor, Resistance, ReloadSpeed, Range, Speed, Silver, Copper, FreeCurrency, + PremiumCurrency, Fame, Alignment, Description) + SELECT I.customerguid, + CD.classid, + UD.userguid, + '', + N.clean_name, + CL.StartingMapName, + CL.X, + CL.Y, + CL.Z, + CL.Perception, + CL.Acrobatics, + CL.Climb, + CL.Stealth, + '', + NOW(), + CL.RX, + CL.RY, + CL.RZ, + CL.Spirit, + CL.Magic, + CL.TeamNumber, + CL.Thirst, + CL.Hunger, + CL.Gold, + CL.Score, + CL.CharacterLevel, + CL.Gender, + CL.XP, + CL.HitDie, + CL.Wounds, + CL.Size, + CL.Weight, + CL.MaxHealth, + CL.Health, + CL.HealthRegenRate, + CL.MaxMana, + CL.Mana, + CL.ManaRegenRate, + CL.MaxEnergy, + CL.Energy, + CL.EnergyRegenRate, + CL.MaxFatigue, + CL.Fatigue, + CL.FatigueRegenRate, + CL.MaxStamina, + CL.Stamina, + CL.StaminaRegenRate, + CL.MaxEndurance, + CL.Endurance, + CL.EnduranceRegenRate, + CL.Strength, + CL.Dexterity, + CL.Constitution, + CL.Intellect, + CL.Wisdom, + CL.Charisma, + CL.Agility, + CL.Fortitude, + CL.Reflex, + CL.Willpower, + CL.BaseAttack, + CL.BaseAttackBonus, + CL.AttackPower, + CL.AttackSpeed, + CL.CritChance, + CL.CritMultiplier, + CL.Haste, + CL.SpellPower, + CL.SpellPenetration, + CL.Defense, + CL.Dodge, + CL.Parry, + CL.Avoidance, + CL.Versatility, + CL.Multishot, + CL.Initiative, + CL.NaturalArmor, + CL.PhysicalArmor, + CL.BonusArmor, + CL.ForceArmor, + CL.MagicArmor, + CL.Resistance, + CL.ReloadSpeed, + CL.Range, + CL.Speed, + CL.Silver, + CL.Copper, + CL.FreeCurrency, + CL.PremiumCurrency, + CL.Fame, + CL.Alignment, + CL.Description + FROM input I + JOIN class_data CD ON TRUE + JOIN user_data UD ON TRUE + JOIN normalized N ON TRUE + JOIN Class CL ON CL.ClassID = CD.classid AND CL.CustomerGUID = I.customerguid + WHERE (SELECT errormessage FROM errors) IS NULL + RETURNING CharacterID, CharName, MapName, X, Y, Z, RX, RY, RZ, TeamNumber, Gold, Silver, Copper, FreeCurrency, + PremiumCurrency, Fame, Alignment, Score, Gender, XP, Size, Weight, CharacterLevel + ), + insert_inventory_bag AS ( + INSERT INTO CharInventory (CustomerGUID, CharacterID, InventoryName, InventorySize, InventoryWidth, InventoryHeight) + SELECT I.customerguid, IC.CharacterID, 'Bag', 16, 4, 4 + FROM input I + JOIN insert_character IC ON TRUE + WHERE (SELECT errormessage FROM errors) IS NULL + AND COALESCE((SELECT count FROM class_inventory_count), 0) < 1 + RETURNING 1 + ), + insert_inventory_class AS ( + INSERT INTO CharInventory (CustomerGUID, CharacterID, InventoryName, InventorySize, InventoryWidth, InventoryHeight) + SELECT I.customerguid, IC.CharacterID, CI.InventoryName, CI.InventorySize, CI.InventoryWidth, CI.InventoryHeight + FROM input I + JOIN insert_character IC ON TRUE + JOIN ClassInventory CI ON CI.CustomerGUID = I.customerguid + JOIN class_data CD ON CI.ClassID = CD.classid + WHERE (SELECT errormessage FROM errors) IS NULL + AND COALESCE((SELECT count FROM class_inventory_count), 0) >= 1 + RETURNING 1 + ) + SELECT E.errormessage AS ErrorMessage, + CASE WHEN E.errormessage IS NULL THEN IC.CharName ELSE '' END AS CharacterName, + CASE WHEN E.errormessage IS NULL THEN I.classname ELSE '' END AS ClassName, + CASE WHEN E.errormessage IS NULL THEN IC.CharacterLevel ELSE 0 END AS CharacterLevel, + CASE WHEN E.errormessage IS NULL THEN IC.MapName ELSE '' END AS StartingMapName, + CASE WHEN E.errormessage IS NULL THEN IC.X ELSE 0 END AS X, + CASE WHEN E.errormessage IS NULL THEN IC.Y ELSE 0 END AS Y, + CASE WHEN E.errormessage IS NULL THEN IC.Z ELSE 0 END AS Z, + CASE WHEN E.errormessage IS NULL THEN IC.RX ELSE 0 END AS RX, + CASE WHEN E.errormessage IS NULL THEN IC.RY ELSE 0 END AS RY, + CASE WHEN E.errormessage IS NULL THEN IC.RZ ELSE 0 END AS RZ, + CASE WHEN E.errormessage IS NULL THEN IC.TeamNumber ELSE 0 END AS TeamNumber, + CASE WHEN E.errormessage IS NULL THEN IC.Gold ELSE 0 END AS Gold, + CASE WHEN E.errormessage IS NULL THEN IC.Silver ELSE 0 END AS Silver, + CASE WHEN E.errormessage IS NULL THEN IC.Copper ELSE 0 END AS Copper, + CASE WHEN E.errormessage IS NULL THEN IC.FreeCurrency ELSE 0 END AS FreeCurrency, + CASE WHEN E.errormessage IS NULL THEN IC.PremiumCurrency ELSE 0 END AS PremiumCurrency, + CASE WHEN E.errormessage IS NULL THEN IC.Fame ELSE 0 END AS Fame, + CASE WHEN E.errormessage IS NULL THEN IC.Alignment ELSE 0 END AS Alignment, + CASE WHEN E.errormessage IS NULL THEN IC.Score ELSE 0 END AS Score, + CASE WHEN E.errormessage IS NULL THEN IC.Gender ELSE 0 END AS Gender, + CASE WHEN E.errormessage IS NULL THEN IC.XP ELSE 0 END AS XP, + CASE WHEN E.errormessage IS NULL THEN IC.Size ELSE 0 END AS Size, + CASE WHEN E.errormessage IS NULL THEN IC.Weight ELSE 0 END AS Weight + FROM errors E + CROSS JOIN input I + LEFT JOIN insert_character IC ON TRUE;"; + public static readonly string AddDefaultCustomCharacterData = @"INSERT INTO CustomCharacterData (CustomerGUID, CharacterID, CustomFieldName, FieldValue) SELECT DCR.CustomerGUID, @CharacterID, DCCD.CustomFieldName, DCCD.FieldValue FROM DefaultCustomCharacterData DCCD From 8e1027d4eaffd40767b94c5830beebba83b297a0 Mon Sep 17 00:00:00 2001 From: SabreDartStudios <108193207+SabreDartStudios@users.noreply.github.com> Date: Sun, 8 Feb 2026 21:06:56 -0500 Subject: [PATCH 2/4] Modified GetPlayerGroupsCharacterIsIn to use a parameterized query instead of a stored proc. --- .../Postgres/UsersRepository.cs | 2 +- src/OWSData/SQL/GenericQueries.cs | 24 ++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs index dc079d98b..8a19c8b96 100644 --- a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs +++ b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs @@ -153,7 +153,7 @@ public async Task> GetPlayerGroupsChar p.Add("@UserSessionGUID", userSessionGUID); p.Add("@PlayerGroupTypeID", playerGroupTypeID); - outputObject = await connection.QueryAsync("select * from GetPlayerGroupsCharacterIsIn(@CustomerGUID,@CharName,@UserSessionGUID,@PlayerGroupTypeID)", + outputObject = await connection.QueryAsync(GenericQueries.GetPlayerGroupsCharacterIsIn, p, commandType: CommandType.Text); } diff --git a/src/OWSData/SQL/GenericQueries.cs b/src/OWSData/SQL/GenericQueries.cs index 67c005a8b..1ee74dcd2 100644 --- a/src/OWSData/SQL/GenericQueries.cs +++ b/src/OWSData/SQL/GenericQueries.cs @@ -330,12 +330,34 @@ FROM CharAbilityBars CAB WHERE C.CustomerGUID = @CustomerGUID AND C.CharName = @CharName"; - public static readonly string GetCharacterCustomDataByName = @"SELECT * + public static readonly string GetCharacterCustomDataByName = @"SELECT * FROM CustomCharacterData CCD INNER JOIN Characters C ON C.CharacterID = CCD.CharacterID WHERE CCD.CustomerGUID = @CustomerGUID AND C.CharName = @CharName"; + public static readonly string GetPlayerGroupsCharacterIsIn = @"SELECT PG.PlayerGroupID, + PG.CustomerGUID, + PG.PlayerGroupName, + PG.PlayerGroupTypeID, + PG.ReadyState, + PG.CreateDate, + PGC.DateAdded, + PGC.TeamNumber + FROM PlayerGroupCharacters PGC + INNER JOIN PlayerGroup PG + ON PG.PlayerGroupID = PGC.PlayerGroupID + AND PG.CustomerGUID = PGC.CustomerGUID + INNER JOIN Characters C + ON C.CharacterID = PGC.CharacterID + INNER JOIN UserSessions US + ON US.UserGUID = C.UserGUID + AND US.CustomerGUID = C.CustomerGUID + WHERE PGC.CustomerGUID = @CustomerGUID + AND (PG.PlayerGroupTypeID = @PlayerGroupTypeID OR @PlayerGroupTypeID = 0) + AND C.CharName = @CharName + AND C.CustomerGUID = @CustomerGUID"; + public static readonly string GetDefaultCustomCharacterDataByDefaultSetName = @"SELECT * FROM DefaultCustomCharacterData DCCD INNER JOIN DefaultCharacterValues DCV From 22fb18774aba1dd46253de69f968b767dbb6ea24 Mon Sep 17 00:00:00 2001 From: SabreDartStudios <108193207+SabreDartStudios@users.noreply.github.com> Date: Tue, 10 Feb 2026 22:12:18 -0500 Subject: [PATCH 3/4] Updated UsersRepository to convert stored procs to parameterized queries. --- .../Postgres/UsersRepository.cs | 20 +- src/OWSData/SQL/GenericQueries.cs | 182 +++++++++++++++++- 2 files changed, 195 insertions(+), 7 deletions(-) diff --git a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs index 8a19c8b96..1bf08178c 100644 --- a/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs +++ b/src/OWSData/Repositories/Implementations/Postgres/UsersRepository.cs @@ -179,7 +179,7 @@ public async Task GetUser(Guid customerGuid, Guid userGuid) p.Add("@CustomerGUID", customerGuid); p.Add("@UserGUID", userGuid); - outputObject = await connection.QuerySingleOrDefaultAsync("select * from GetUser(@CustomerGUID,@UserGUID)", + outputObject = await connection.QuerySingleOrDefaultAsync(GenericQueries.GetUser, p, commandType: CommandType.Text); @@ -227,7 +227,7 @@ public async Task GetUserSession(Guid customerGuid, Guid userSes p.Add("@CustomerGUID", customerGuid); p.Add("@UserSessionGUID", userSessionGuid); - outputObject = await connection.QuerySingleOrDefaultAsync("select * from GetUserSession(@CustomerGUID,@UserSessionGUID)", + outputObject = await connection.QuerySingleOrDefaultAsync(GenericQueries.GetUserSession, p, commandType: CommandType.Text); @@ -305,7 +305,7 @@ public async Task LoginAndCreateSession(Guid custom p.Add("@Password", password); p.Add("@DontCheckPassword", dontCheckPassword); - outputObject = await connection.QuerySingleOrDefaultAsync($"select * from PlayerLoginAndCreateSession(@CustomerGUID,@Email,@Password,@DontCheckPassword)", + outputObject = await connection.QuerySingleOrDefaultAsync(GenericQueries.PlayerLoginAndCreateSession, p, commandType: CommandType.Text); } @@ -355,7 +355,7 @@ public async Task UserSessionSetSelectedCharacter(Guid c p.Add("@UserSessionGUID", userSessionGUID); p.Add("@SelectedCharacterName", selectedCharacterName); - await connection.ExecuteAsync("call UserSessionSetSelectedCharacter(@CustomerGUID, @UserSessionGUID, @SelectedCharacterName)", + await connection.ExecuteAsync(GenericQueries.UserSessionSetSelectedCharacter, p, commandType: CommandType.Text); } @@ -390,7 +390,7 @@ public async Task RegisterUser(Guid customerGUID, string p.Add("@LastName", lastName); p.Add("@Role", "Player"); - await connection.ExecuteAsync("select * from AddUser(@CustomerGUID, @FirstName, @LastName, @Email, @Password, @Role)", + await connection.ExecuteAsync(GenericQueries.AddUser, p, commandType: CommandType.Text); } @@ -427,14 +427,22 @@ public async Task RemoveCharacter(Guid customerGUID, Gui { using (var connection = (NpgsqlConnection)Connection) { + await connection.OpenAsync(); + + using (IDbTransaction transaction = connection.BeginTransaction()) + { var p = new DynamicParameters(); p.Add("@CustomerGUID", customerGUID); p.Add("@UserSessionGUID", userSessionGUID); p.Add("@CharacterName", characterName); - await connection.ExecuteAsync("call RemoveCharacter(@CustomerGUID,@UserSessionGUID,@CharacterName)", + await connection.ExecuteAsync(GenericQueries.RemoveCharacter, p, + transaction, commandType: CommandType.Text); + + transaction.Commit(); + } } return new SuccessAndErrorMessage() diff --git a/src/OWSData/SQL/GenericQueries.cs b/src/OWSData/SQL/GenericQueries.cs index 1ee74dcd2..5d25a6d35 100644 --- a/src/OWSData/SQL/GenericQueries.cs +++ b/src/OWSData/SQL/GenericQueries.cs @@ -373,10 +373,15 @@ FROM PlayerGroupCharacters PGC AND PGC.CharacterID = @CharacterID AND PG.PlayerGroupTypeID = @PlayerGroupType"; - public static readonly string GetUsers = @"SELECT UserGUID, FirstName, LastName, Email, CreateDate, LastAccess, Role + public static readonly string GetUsers = @"SELECT UserGUID, FirstName, LastName, Email, CreateDate, LastAccess, Role FROM Users WHERE CustomerGUID = @CustomerGUID"; + public static readonly string GetUser = @"SELECT * + FROM Users + WHERE CustomerGUID = @CustomerGUID + AND UserGUID = @UserGUID"; + public static readonly string UpdateUser = @"UPDATE Users SET FirstName = @FirstName , LastName = @LastName @@ -616,6 +621,181 @@ FROM GlobalData GD public static readonly string Logout = @"DELETE FROM UserSessions WHERE CustomerGUID=@CustomerGuid AND UserSessionGUID=@UserSessionGUID"; + public static readonly string GetUserSession = @"SELECT US.CustomerGUID, + US.UserGUID, + US.UserSessionGUID, + US.LoginDate, + US.SelectedCharacterName, + U.Email, + U.FirstName, + U.LastName, + U.CreateDate, + U.LastAccess, + U.Role, + C.CharacterID, + C.CharName, + C.X, + C.Y, + C.Z, + C.RX, + C.RY, + C.RZ, + C.MapName AS ZoneName + FROM UserSessions US + INNER JOIN Users U + ON U.UserGUID = US.UserGUID + LEFT JOIN Characters C + ON C.CustomerGUID = US.CustomerGUID + AND C.CharName = US.SelectedCharacterName + AND C.UserGUID = US.UserGUID + WHERE US.CustomerGUID = @CustomerGUID + AND US.UserSessionGUID = @UserSessionGUID"; + + public static readonly string PlayerLoginAndCreateSession = @"WITH user_row AS ( + SELECT U.UserGUID, + (U.PasswordHash = crypt(@Password, U.PasswordHash)) AS PasswordCheck + FROM Users U + WHERE U.CustomerGUID = @CustomerGUID + AND U.Email = @Email + AND U.Role = 'Player' + ), + auth AS ( + SELECT CASE + WHEN (PasswordCheck OR @DontCheckPassword) THEN TRUE + ELSE FALSE + END AS Authenticated, + UserGUID + FROM user_row + ), + deleted AS ( + DELETE FROM UserSessions US + USING auth A + WHERE A.Authenticated = TRUE + AND US.UserGUID = A.UserGUID + RETURNING 1 + ), + ins AS ( + INSERT INTO UserSessions (CustomerGUID, UserSessionGUID, UserGUID, LoginDate) + SELECT @CustomerGUID, gen_random_uuid(), A.UserGUID, NOW() + FROM auth A + WHERE A.Authenticated = TRUE + RETURNING UserSessionGUID + ) + SELECT COALESCE((SELECT Authenticated FROM auth), FALSE) AS Authenticated, + (SELECT UserSessionGUID FROM ins) AS UserSessionGUID"; + + public static readonly string UserSessionSetSelectedCharacter = @"UPDATE UserSessions + SET SelectedCharacterName = @SelectedCharacterName + WHERE CustomerGUID = @CustomerGUID + AND UserSessionGUID = @UserSessionGUID"; + + public static readonly string AddUser = @"WITH new_user AS ( + SELECT gen_random_uuid() AS UserGUID, + crypt(@Password, gen_salt('md5')) AS PasswordHash + ), + ins AS ( + INSERT INTO Users (UserGUID, CustomerGUID, FirstName, LastName, Email, PasswordHash, CreateDate, LastAccess, Role) + SELECT NU.UserGUID, @CustomerGUID, @FirstName, @LastName, @Email, NU.PasswordHash, NOW(), NOW(), @Role + FROM new_user NU + RETURNING UserGUID + ) + SELECT UserGUID FROM ins"; + + public static readonly string RemoveCharacter = @"WITH user_data AS ( + SELECT US.UserGUID + FROM UserSessions US + WHERE US.CustomerGUID = @CustomerGUID + AND US.UserSessionGUID = @UserSessionGUID + ), + char_data AS ( + SELECT C.CharacterID + FROM Characters C + JOIN user_data UD ON C.UserGUID = UD.UserGUID + WHERE C.CustomerGUID = @CustomerGUID + AND C.CharName = @CharacterName + ), + del_char_ability_bar_abilities AS ( + DELETE FROM CharAbilityBarAbilities CABA + USING CharAbilityBars CAB, char_data CD + WHERE CABA.CustomerGUID = @CustomerGUID + AND CABA.CharAbilityBarID = CAB.CharAbilityBarID + AND CAB.CustomerGUID = @CustomerGUID + AND CAB.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_ability_bars AS ( + DELETE FROM CharAbilityBars CAB + USING char_data CD + WHERE CAB.CustomerGUID = @CustomerGUID + AND CAB.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_has_abilities AS ( + DELETE FROM CharHasAbilities CHA + USING char_data CD + WHERE CHA.CustomerGUID = @CustomerGUID + AND CHA.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_has_items AS ( + DELETE FROM CharHasItems CHI + USING char_data CD + WHERE CHI.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_inventory_items AS ( + DELETE FROM CharInventoryItems CII + USING CharInventory CI, char_data CD + WHERE CII.CustomerGUID = @CustomerGUID + AND CII.CharInventoryID = CI.CharInventoryID + AND CI.CustomerGUID = @CustomerGUID + AND CI.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_inventory AS ( + DELETE FROM CharInventory CI + USING char_data CD + WHERE CI.CustomerGUID = @CustomerGUID + AND CI.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_char_on_map_instance AS ( + DELETE FROM CharOnMapInstance COMI + USING char_data CD + WHERE COMI.CustomerGUID = @CustomerGUID + AND COMI.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_chat_group_users AS ( + DELETE FROM ChatGroupUsers CGU + USING char_data CD + WHERE CGU.CustomerGUID = @CustomerGUID + AND CGU.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_custom_character_data AS ( + DELETE FROM CustomCharacterData CCD + USING char_data CD + WHERE CCD.CustomerGUID = @CustomerGUID + AND CCD.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_player_group_characters AS ( + DELETE FROM PlayerGroupCharacters PGC + USING char_data CD + WHERE PGC.CustomerGUID = @CustomerGUID + AND PGC.CharacterID = CD.CharacterID + RETURNING 1 + ), + del_characters AS ( + DELETE FROM Characters C + USING char_data CD + WHERE C.CustomerGUID = @CustomerGUID + AND C.CharacterID = CD.CharacterID + RETURNING 1 + ) + SELECT 1"; + #endregion } } From 4e819e6859424fbf0b3b36f686e6962e0a3c05d0 Mon Sep 17 00:00:00 2001 From: SabreDartStudios <108193207+SabreDartStudios@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:40:19 -0500 Subject: [PATCH 4/4] Add UserSession ValKey server (work in process). Improved error logging for GetServerToConnectTo. --- src/.docker/usersessioncache.yml | 17 +++++ src/.env | 5 +- src/OWSData/OWSData.csproj | 3 +- .../ValKey/UserSessionRepository.cs | 68 +++++++++++++++++++ .../Interfaces/IUserSessionRepository.cs | 12 ++++ .../Services/ServerLauncherMQListener.cs | 2 +- .../Users/GetServerToConnectToRequest.cs | 34 ++++++++-- src/docker-compose.yml | 8 +++ 8 files changed, 140 insertions(+), 9 deletions(-) create mode 100644 src/.docker/usersessioncache.yml create mode 100644 src/OWSData/Repositories/Implementations/ValKey/UserSessionRepository.cs create mode 100644 src/OWSData/Repositories/Interfaces/IUserSessionRepository.cs diff --git a/src/.docker/usersessioncache.yml b/src/.docker/usersessioncache.yml new file mode 100644 index 000000000..769d43206 --- /dev/null +++ b/src/.docker/usersessioncache.yml @@ -0,0 +1,17 @@ +version: '3.7' + +services: + usersessioncache: + hostname: 'ows2' + container_name: usersessioncache + image: valkey/valkey:latest + restart: always + command: valkey-server --appendonly yes --requirepass "${UserSessionCacheValkeyPassword}" + ports: + - "6379:6379" + volumes: + - usersessioncache:/data + environment: + TZ=America/New_York + volumes: + usersessioncache: \ No newline at end of file diff --git a/src/.env b/src/.env index cf563f9e8..98ddef342 100644 --- a/src/.env +++ b/src/.env @@ -54,4 +54,7 @@ RabbitMQUserName="dev" RabbitMQPassword="test" # Matchmaking Cache Redis -# MatchmakingCacheRedisPassword='YourRedi$Pa$$word' \ No newline at end of file +# MatchmakingCacheRedisPassword='YourRedi$Pa$$word' + +# User Session Cache Redis +# UserSessionCacheValkeyPassword='YourValkeyPa$$word' \ No newline at end of file diff --git a/src/OWSData/OWSData.csproj b/src/OWSData/OWSData.csproj index 28a5108fa..32f7ed23a 100644 --- a/src/OWSData/OWSData.csproj +++ b/src/OWSData/OWSData.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -13,6 +13,7 @@ + diff --git a/src/OWSData/Repositories/Implementations/ValKey/UserSessionRepository.cs b/src/OWSData/Repositories/Implementations/ValKey/UserSessionRepository.cs new file mode 100644 index 000000000..e5ad1f05e --- /dev/null +++ b/src/OWSData/Repositories/Implementations/ValKey/UserSessionRepository.cs @@ -0,0 +1,68 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using OWSData.Models.StoredProcs; +using OWSData.Repositories.Interfaces; +using StackExchange.Redis; + +namespace OWSData.Repositories.Implementations.ValKey +{ + public class UserSessionRepository : IUserSessionRepository + { + private const string ValKeyConnectionString = "localhost:6379,password=YourValkeyPa$$word"; + private static readonly Lazy Connection = + new(() => ConnectionMultiplexer.Connect(ValKeyConnectionString)); + + private static IDatabase Database => Connection.Value.GetDatabase(); + private static string keyPrefix = "user:session:"; + + private string constructUserSessionKeyFromUserGuid(Guid UserGuid) + { + return $"{keyPrefix}{UserGuid.ToString()}"; + } + + public async Task GetUserSession(Guid UserGuid) + { + string key = constructUserSessionKeyFromUserGuid(UserGuid); + ValidateKey(key); + + RedisValue userSessionJson = await Database.StringGetAsync(key); + if (userSessionJson.IsNullOrEmpty) + { + return new GetUserSession(); + } + + try + { + var userSession = JsonSerializer.Deserialize(userSessionJson!); + return userSession ?? new GetUserSession(); + } + catch (JsonException) + { + return new GetUserSession(); + } + } + + public async Task SetUserSession(Guid UserGuid, GetUserSession userSession) + { + string key = constructUserSessionKeyFromUserGuid(UserGuid); + ValidateKey(key); + + if (userSession == null) + { + throw new ArgumentNullException(nameof(userSession)); + } + + string userSessionJson = JsonSerializer.Serialize(userSession); + await Database.StringSetAsync(key, userSessionJson); + } + + private static void ValidateKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException("A non-empty session key is required.", nameof(key)); + } + } + } +} diff --git a/src/OWSData/Repositories/Interfaces/IUserSessionRepository.cs b/src/OWSData/Repositories/Interfaces/IUserSessionRepository.cs new file mode 100644 index 000000000..0427e7308 --- /dev/null +++ b/src/OWSData/Repositories/Interfaces/IUserSessionRepository.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading.Tasks; +using OWSData.Models.StoredProcs; + +namespace OWSData.Repositories.Interfaces +{ + public interface IUserSessionRepository + { + Task GetUserSession(Guid UserGuid); + Task SetUserSession(Guid UserGuid, GetUserSession userSession); + } +} diff --git a/src/OWSInstanceLauncher/Services/ServerLauncherMQListener.cs b/src/OWSInstanceLauncher/Services/ServerLauncherMQListener.cs index 42dff2ce1..9407acf34 100644 --- a/src/OWSInstanceLauncher/Services/ServerLauncherMQListener.cs +++ b/src/OWSInstanceLauncher/Services/ServerLauncherMQListener.cs @@ -187,9 +187,9 @@ public void DoWork() serverSpinUpConsumer.Received += (model, ea) => { - Log.Information("Server Spin Up Message Received"); var body = ea.Body; MQSpinUpServerMessage serverSpinUpMessage = MQSpinUpServerMessage.Deserialize(body.ToArray()); + Log.Information($"Server Spin Up Message Received: {serverSpinUpMessage.CustomerGUID} WorldServerID: {serverSpinUpMessage.WorldServerID} ZoneInstanceID: {serverSpinUpMessage.ZoneInstanceID} MapName: {serverSpinUpMessage.MapName} Port: {serverSpinUpMessage.Port}"); HandleServerSpinUpMessage( serverSpinUpMessage.CustomerGUID, serverSpinUpMessage.WorldServerID, diff --git a/src/OWSPublicAPI/Requests/Users/GetServerToConnectToRequest.cs b/src/OWSPublicAPI/Requests/Users/GetServerToConnectToRequest.cs index 1c8dd8971..9f3a69665 100644 --- a/src/OWSPublicAPI/Requests/Users/GetServerToConnectToRequest.cs +++ b/src/OWSPublicAPI/Requests/Users/GetServerToConnectToRequest.cs @@ -1,15 +1,16 @@ using Microsoft.AspNetCore.Mvc; -using System; -using System.Threading.Tasks; +using Microsoft.Extensions.Options; using OWSData.Models.StoredProcs; using OWSData.Repositories.Interfaces; using OWSShared.Interfaces; using OWSShared.Options; using OWSShared.RequestPayloads; -using Microsoft.Extensions.Options; +using Serilog; +using System; using System.Net.Http; using System.Text; using System.Text.Json; +using System.Threading.Tasks; namespace OWSPublicAPI.Requests.Users { @@ -40,6 +41,7 @@ public void SetData(IOptions owsGeneralConfig, IUsersRepositor public async Task Handle() { + Log.Information($"GetServerToConnectTo: Started..."); Output = new JoinMapByCharName(); //If ZoneName is empty, look it up from the character. This is used for the inital login. @@ -54,11 +56,13 @@ public async Task Handle() } ZoneName = character.MapName; + Log.Information($"GetServerToConnectTo: Using last ZoneName for CharacterName: {CharacterName} ZoneName: {ZoneName}"); } //If the ZoneName is empty, return an error if (String.IsNullOrEmpty(ZoneName)) { + Log.Error($"GetServerToConnectTo: ZoneName is NULL or Empty. Make sure the character is assigned to a Zone!"); Output.Success = false; Output.ErrorMessage = "GetServerToConnectTo: ZoneName is NULL or Empty. Make sure the character is assigned to a Zone!"; return new OkObjectResult(Output); @@ -70,6 +74,7 @@ public async Task Handle() if (joinMapByCharacterName == null || joinMapByCharacterName.WorldServerID < 1) { + Log.Error($"GetServerToConnectTo: WorldServerID is less than 1. Make sure you setup at least one valid World Server and that it is currently running!"); Output.Success = false; Output.ErrorMessage = "GetServerToConnectTo: WorldServerID is less than 1. Make sure you setup at least one valid World Server and that it is currently running!"; return new OkObjectResult(Output); @@ -78,6 +83,7 @@ public async Task Handle() //There is no zone server running that will accept our connection, so start up a new one if (joinMapByCharacterName.NeedToStartupMap) { + Log.Information($"GetServerToConnectTo: Starting up server instance MapInstanceID: {joinMapByCharacterName.MapInstanceID}"); bool requestSuccess = await RequestServerSpinUp(joinMapByCharacterName.WorldServerID, joinMapByCharacterName.MapInstanceID, joinMapByCharacterName.MapNameToStart, joinMapByCharacterName.Port); //Wait OWSGeneralConfig.SecondsToWaitBeforeFirstPollForSpinUp seconds before the first CheckMapInstanceStatus to give it time to spin up @@ -88,12 +94,14 @@ public async Task Handle() //We found a zone server we can connect to, but it is still spinning up. Wait until it is ready to connect to (up to OWSGeneralConfig.SecondsToWaitForServerSpinUp seconds). else if (joinMapByCharacterName.MapInstanceID > 0 && joinMapByCharacterName.MapInstanceStatus == 1) { + Log.Information($"GetServerToConnectTo: We found a zone server we can connect to, but it is still spinning up. Wait until it is ready to connect to (up to OWSGeneralConfig.SecondsToWaitForServerSpinUp seconds)"); //CheckMapInstanceStatus every OWSGeneralConfig.SecondsToWaitInBetweenSpinUpPolling seconds for up to OWSGeneralConfig.SecondsToWaitForServerSpinUp seconds readyForPlayersToConnect = await WaitForServerReadyToConnect(CustomerGUID, joinMapByCharacterName.MapInstanceID); } //We found a zone server we can connect to and it is ready to connect else if (joinMapByCharacterName.MapInstanceID > 0 && joinMapByCharacterName.MapInstanceStatus == 2) { + Log.Information($"GetServerToConnectTo: We found a zone server we can connect to and it is ready to connect"); //The zone server is ready to connect to readyForPlayersToConnect = true; } @@ -101,6 +109,7 @@ public async Task Handle() //The zone instance is ready, so connect the character to the map instance in our data store if (readyForPlayersToConnect) { + Log.Information($"GetServerToConnectTo: The zone instance is ready, so connect the character to the map instance in our data store"); await charactersRepository.AddCharacterToMapInstanceByCharName(CustomerGUID, CharacterName, joinMapByCharacterName.MapInstanceID); } @@ -114,21 +123,31 @@ private async Task WaitForServerReadyToConnect(Guid customerGUID, int zone { DateTime StartPollingTime = DateTime.Now; + int retryCount = 0; while (DateTime.Now < StartPollingTime.AddSeconds(owsGeneralConfig.Value.SecondsToWaitForServerSpinUp)) { + int retrySeconds = owsGeneralConfig.Value.SecondsToWaitInBetweenSpinUpPolling * 1000; + if (retryCount < 1) + { + retrySeconds = owsGeneralConfig.Value.SecondsToWaitBeforeFirstPollForSpinUp * 1000; + } + //Check Map Status + Log.Information($"GetServerToConnectTo: Checking if the server instance is ready to play. Check every {retrySeconds / 1000} seconds..."); var resultCheckMapInstanceStatus = await charactersRepository.CheckMapInstanceStatus(CustomerGUID, zoneInstanceID); if (resultCheckMapInstanceStatus.Status == 2) //Ready to play { + Log.Information($"GetServerToConnectTo: The server is ready to play."); return true; } - - System.Threading.Thread.Sleep(owsGeneralConfig.Value.SecondsToWaitInBetweenSpinUpPolling); + + System.Threading.Thread.Sleep(retrySeconds); + retryCount++; } //The server did not spin up in time so shut it down - + Log.Information($"GetServerToConnectTo: The server did not spin up in time. Shut it down."); return false; } @@ -149,14 +168,17 @@ private async Task RequestServerSpinUp(int worldServerID, int zoneInstance var serverSpinUpPayload = new StringContent(JsonSerializer.Serialize(spinUpServerInstanceRequestPayload), Encoding.UTF8, "application/json"); + Log.Information($"GetServerToConnectTo: Calling the Instance Manager to spin up the server instance on WorldServerId: {worldServerID} ZoneInstanceID: {zoneInstanceID} UE Map Name: {zoneName} Port: {port}"); var responseMessage = await instanceManagementHttpClient.PostAsync("api/Instance/SpinUpServerInstance", serverSpinUpPayload); if (responseMessage.IsSuccessStatusCode) { + Log.Information($"GetServerToConnectTo: Successfully called the Instance Manager to spin up the server: {CustomerGUID} {worldServerID} {zoneInstanceID} {zoneName} {port}"); return true; } else { + Log.Error($"GetServerToConnectTo: Failed to call the Instance Manager to spin up the server: {CustomerGUID} {worldServerID} {zoneInstanceID} {zoneName} {port}"); return false; } } diff --git a/src/docker-compose.yml b/src/docker-compose.yml index 17c5e6a32..fceb68691 100644 --- a/src/docker-compose.yml +++ b/src/docker-compose.yml @@ -55,6 +55,12 @@ services: # file: .docker/matchmakingcache.yml # service: matchmakingcache + # User Session Cache (ValKey) + usersessioncache: + extends: + file: .docker/usersessioncache.yml + service: usersessioncache + # OWS Public Api owspublicapi: image: ${REGISTRY:-ows}/owspublicapi:${PLATFORM:-linux}-${TAG:-latest} @@ -176,3 +182,5 @@ volumes: name: "ows2-messaging" matchmakingcache: name: "ows2-matchmakingcache" + usersessioncache: + name: "ows2-usersessioncache"