diff --git a/Maple2.Database/Model/Map/InteractCube.cs b/Maple2.Database/Model/Map/InteractCube.cs index cb380637a..5f36d21d8 100644 --- a/Maple2.Database/Model/Map/InteractCube.cs +++ b/Maple2.Database/Model/Map/InteractCube.cs @@ -16,12 +16,16 @@ internal record InteractCube( other.Id, other.ObjectCode, other.PortalSettings, - other.NoticeSettings); + other.NoticeSettings ?? (other.TriggerSettings == null ? null : new Maple2.Model.Game.CubeNoticeSettings { + Notice = other.TriggerSettings.ScriptXml, + Distance = other.TriggerSettings.Distance, + })); } // Use explicit Convert() here because we need metadata to construct InteractCube. public Maple2.Model.Game.InteractCube Convert(FunctionCubeMetadata metadata, CubeNoticeSettings? noticeSettings, CubePortalSettings? portalSettings) { - return new Maple2.Model.Game.InteractCube(Id, metadata, portalSettings, noticeSettings); + Maple2.Model.Game.InteractCube result = new Maple2.Model.Game.InteractCube(Id, metadata, portalSettings, noticeSettings); + return result; } } diff --git a/Maple2.Database/Storage/Game/GameStorage.Guild.cs b/Maple2.Database/Storage/Game/GameStorage.Guild.cs index f4f771d38..893d3f1cf 100644 --- a/Maple2.Database/Storage/Game/GameStorage.Guild.cs +++ b/Maple2.Database/Storage/Game/GameStorage.Guild.cs @@ -2,6 +2,7 @@ using Maple2.Model.Enum; using Maple2.Model.Game; using Maple2.Tools.Extensions; +using Microsoft.EntityFrameworkCore; using Z.EntityFramework.Plus; namespace Maple2.Database.Storage; @@ -42,6 +43,140 @@ public IList GetGuildMembers(IPlayerInfoProvider provider, long gui .ToList(); } + + public IList SearchGuilds(IPlayerInfoProvider provider, string guildName = "", GuildFocus? focus = null, int limit = 50) { + IQueryable query = Context.Guild; + if (!string.IsNullOrWhiteSpace(guildName)) { + query = query.Where(guild => EF.Functions.Like(guild.Name, $"%{guildName}%")); + } + if (focus.HasValue && (int) focus.Value != 0) { + query = query.Where(guild => guild.Focus == focus.Value); + } + + List guildIds = query.OrderBy(guild => guild.Name) + .Take(limit) + .Select(guild => guild.Id) + .ToList(); + + var result = new List(); + foreach (long id in guildIds) { + Guild? guild = LoadGuild(id, string.Empty); + if (guild == null) { + continue; + } + + foreach (GuildMember member in GetGuildMembers(provider, id)) { + guild.Members.TryAdd(member.CharacterId, member); + guild.AchievementInfo += member.Info.AchievementInfo; + } + result.Add(guild); + } + + return result; + } + + public GuildApplication? CreateGuildApplication(IPlayerInfoProvider provider, long guildId, long applicantId) { + Guild? guild = LoadGuild(guildId, string.Empty); + PlayerInfo? applicant = provider.GetPlayerInfo(applicantId); + if (guild == null || applicant == null) { + return null; + } + + if (Context.GuildApplication.Any(app => app.GuildId == guildId && app.ApplicantId == applicantId)) { + Model.GuildApplication existing = Context.GuildApplication.First(app => app.GuildId == guildId && app.ApplicantId == applicantId); + return new GuildApplication { + Id = existing.Id, + Guild = guild, + Applicant = applicant, + CreationTime = existing.CreationTime.ToEpochSeconds(), + }; + } + + var app = new Model.GuildApplication { + GuildId = guildId, + ApplicantId = applicantId, + }; + Context.GuildApplication.Add(app); + if (!SaveChanges()) { + return null; + } + + return new GuildApplication { + Id = app.Id, + Guild = guild, + Applicant = applicant, + CreationTime = app.CreationTime.ToEpochSeconds(), + }; + } + + public GuildApplication? GetGuildApplication(IPlayerInfoProvider provider, long applicationId) { + Model.GuildApplication? app = Context.GuildApplication.FirstOrDefault(app => app.Id == applicationId); + if (app == null) { + return null; + } + + Guild? guild = LoadGuild(app.GuildId, string.Empty); + PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId); + if (guild == null || applicant == null) { + return null; + } + + return new GuildApplication { + Id = app.Id, + Guild = guild, + Applicant = applicant, + CreationTime = app.CreationTime.ToEpochSeconds(), + }; + } + + public IList GetGuildApplications(IPlayerInfoProvider provider, long guildId) { + List applications = Context.GuildApplication.Where(app => app.GuildId == guildId) + .OrderByDescending(app => app.CreationTime) + .ToList(); + + return applications + .Select(app => { + Guild? guild = LoadGuild(app.GuildId, string.Empty); + PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId); + if (guild == null || applicant == null) { + return null; + } + + return new GuildApplication { + Id = app.Id, + Guild = guild, + Applicant = applicant, + CreationTime = app.CreationTime.ToEpochSeconds(), + }; + }) + .WhereNotNull() + .ToList(); + } + + public IList GetGuildApplicationsByApplicant(IPlayerInfoProvider provider, long applicantId) { + List applications = Context.GuildApplication.Where(app => app.ApplicantId == applicantId) + .OrderByDescending(app => app.CreationTime) + .ToList(); + + return applications + .Select(app => { + Guild? guild = LoadGuild(app.GuildId, string.Empty); + PlayerInfo? applicant = provider.GetPlayerInfo(app.ApplicantId); + if (guild == null || applicant == null) { + return null; + } + + return new GuildApplication { + Id = app.Id, + Guild = guild, + Applicant = applicant, + CreationTime = app.CreationTime.ToEpochSeconds(), + }; + }) + .WhereNotNull() + .ToList(); + } + public Guild? CreateGuild(string name, long leaderId) { BeginTransaction(); @@ -155,20 +290,18 @@ public bool DeleteGuildApplications(long characterId) { public bool SaveGuildMembers(long guildId, ICollection members) { Dictionary saveMembers = members .ToDictionary(member => member.CharacterId, member => member); - IEnumerable existingMembers = Context.GuildMember + HashSet existingMembers = Context.GuildMember .Where(member => member.GuildId == guildId) - .Select(member => new Model.GuildMember { - CharacterId = member.CharacterId, - }); + .Select(member => member.CharacterId) + .ToHashSet(); - foreach (Model.GuildMember member in existingMembers) { - if (saveMembers.Remove(member.CharacterId, out GuildMember? gameMember)) { + foreach ((long characterId, GuildMember gameMember) in saveMembers) { + if (existingMembers.Contains(characterId)) { Context.GuildMember.Update(gameMember); } else { - Context.GuildMember.Remove(member); + Context.GuildMember.Add(gameMember); } } - Context.GuildMember.AddRange(saveMembers.Values.Select(member => member)); return SaveChanges(); } diff --git a/Maple2.Database/Storage/Game/GameStorage.User.cs b/Maple2.Database/Storage/Game/GameStorage.User.cs index 35dd05f68..722cd849f 100644 --- a/Maple2.Database/Storage/Game/GameStorage.User.cs +++ b/Maple2.Database/Storage/Game/GameStorage.User.cs @@ -582,9 +582,9 @@ public Account CreateAccount(Account account, string password) { model.Password = BCrypt.Net.BCrypt.HashPassword(password, 13); #if DEBUG model.Currency = new AccountCurrency { - Meret = 9_999_999, + Meret = 1_000_00, }; - model.Permissions = AdminPermissions.Admin.ToString(); +// model.Permissions = AdminPermissions.Admin.ToString(); #endif Context.Account.Add(model); Context.SaveChanges(); // Exception if failed. @@ -609,7 +609,7 @@ public Account CreateAccount(Account account, string password) { model.Channel = -1; #if DEBUG model.Currency = new CharacterCurrency { - Meso = 999999999, + Meso = 500000, }; #endif Context.Character.Add(model); diff --git a/Maple2.Database/Storage/Metadata/NpcMetadataStorage.cs b/Maple2.Database/Storage/Metadata/NpcMetadataStorage.cs index 24006d640..d94bae6ce 100644 --- a/Maple2.Database/Storage/Metadata/NpcMetadataStorage.cs +++ b/Maple2.Database/Storage/Metadata/NpcMetadataStorage.cs @@ -3,6 +3,10 @@ using Maple2.Database.Context; using Maple2.Model.Metadata; using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; namespace Maple2.Database.Storage; @@ -13,6 +17,125 @@ public class NpcMetadataStorage : MetadataStorage, ISearchable private readonly Dictionary> tagLookup; protected readonly LRUCache AniCache; + private static string NormalizeAiPath(string value) { + return value.Replace('\\', '/'); + } + + private static string GetDirectoryPath(string aiPath) { + string normalized = NormalizeAiPath(aiPath); + int index = normalized.LastIndexOf('/'); + return index >= 0 ? normalized.Substring(0, index) : string.Empty; + } + + private static string GetFileNameWithoutExtensionSafe(string aiPath) { + string normalized = NormalizeAiPath(aiPath); + string fileName = Path.GetFileNameWithoutExtension(normalized); + return fileName ?? string.Empty; + } + + public NpcMetadata? FindByAiPath(string aiPath, int preferredDifficulty = -1, int preferredId = 0) { + string normalized = NormalizeAiPath(aiPath); + + lock (Context) { + List candidates = Context.NpcMetadata + .Where(npc => npc.AiPath != null) + .AsEnumerable() + .Where(npc => NormalizeAiPath(npc.AiPath!).Equals(normalized, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (candidates.Count == 0) { + return null; + } + + if (preferredDifficulty >= 0) { + List difficultyMatches = candidates + .Where(npc => npc.Basic.Difficulty == preferredDifficulty) + .ToList(); + + if (difficultyMatches.Count > 0) { + candidates = difficultyMatches; + } + } + + if (preferredId != 0) { + NpcMetadata? closestId = candidates + .OrderBy(npc => Math.Abs(npc.Id - preferredId)) + .FirstOrDefault(); + + if (closestId != null) { + return closestId; + } + } + + return candidates.FirstOrDefault(); + } + } + + public NpcMetadata? FindByRelativeAiAlias(string currentAiPath, int aliasId) { + if (string.IsNullOrWhiteSpace(currentAiPath) || aliasId <= 0) { + return null; + } + + string normalizedCurrent = NormalizeAiPath(currentAiPath); + string currentDirectory = GetDirectoryPath(normalizedCurrent); + string currentFileName = GetFileNameWithoutExtensionSafe(normalizedCurrent); + + if (string.IsNullOrWhiteSpace(currentDirectory)) { + return null; + } + + List candidates; + lock (Context) { + candidates = Context.NpcMetadata + .Where(npc => npc.AiPath != null) + .AsEnumerable() + .Where(npc => NormalizeAiPath(npc.AiPath!).StartsWith(currentDirectory + "/", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + if (candidates.Count == 0) { + return null; + } + + string[] directCandidates = { + $"{currentDirectory}/AI_{aliasId}.xml", + $"{currentDirectory}/{aliasId}.xml", + $"{currentDirectory}/{currentFileName}_{aliasId}.xml", + $"{currentDirectory}/{currentFileName}Type{aliasId}.xml", + $"{currentDirectory}/{currentFileName}_Type{aliasId}.xml", + $"{currentDirectory}/{currentFileName}Summon{aliasId}.xml", + $"{currentDirectory}/{currentFileName}_Summon{aliasId}.xml" + }; + + foreach (string candidate in directCandidates) { + NpcMetadata? exact = candidates.FirstOrDefault(npc => + NormalizeAiPath(npc.AiPath!).Equals(candidate, StringComparison.OrdinalIgnoreCase)); + + if (exact != null) { + return exact; + } + } + + string[] aliasTokens = { + $"_{aliasId}", + $"Type{aliasId}", + $"Summon{aliasId}", + $"Soldier{aliasId}", + $"Mob{aliasId}", + $"{aliasId}.xml" + }; + + NpcMetadata? tokenMatch = candidates.FirstOrDefault(npc => { + string fileName = Path.GetFileName(NormalizeAiPath(npc.AiPath!)); + return aliasTokens.Any(token => fileName.Contains(token, StringComparison.OrdinalIgnoreCase)); + }); + + if (tokenMatch != null) { + return tokenMatch; + } + + return null; + } public NpcMetadataStorage(MetadataContext context) : base(context, CACHE_SIZE) { tagLookup = new Dictionary>(); diff --git a/Maple2.Model/Enum/GameEventUserValueType.cs b/Maple2.Model/Enum/GameEventUserValueType.cs index 7547f6474..9d74d5eb9 100644 --- a/Maple2.Model/Enum/GameEventUserValueType.cs +++ b/Maple2.Model/Enum/GameEventUserValueType.cs @@ -6,14 +6,10 @@ public enum GameEventUserValueType { // Attendance Event AttendanceActive = 100, //?? maybe. String is "True" AttendanceCompletedTimestamp = 101, - AttendanceRewardsClaimed = 102, // Also used for Cash Attendance and DT Attendance + AttendanceRewardsClaimed = 102, AttendanceEarlyParticipationRemaining = 103, - AttendanceNear = 105, AttendanceAccumulatedTime = 106, - // ReturnUser - ReturnUser = 320, // IsReturnUser - // DTReward DTRewardStartTime = 700, // start time DTRewardCurrentTime = 701, // current item accumulated time @@ -29,22 +25,10 @@ public enum GameEventUserValueType { GalleryCardFlipCount = 1600, GalleryClaimReward = 1601, - // Snowman Event - SnowflakeCount = 1700, - DailyCompleteCount = 1701, - AccumCompleteCount = 1702, - AccumCompleteRewardReceived = 1703, - // Rock Paper Scissors Event RPSDailyMatches = 1800, RPSRewardsClaimed = 1801, - // Couple Dance - CoupleDanceBannerOpen = 2100, - CoupleDanceRewardState = 2101, // completed/bonus/received flags - - CollectItemGroup = 2200, // Meta Badge event. Serves as a flag for tiers - // Bingo - TODO: These are not the actual confirmed values. Just using it as a way to store this data for now. BingoUid = 4000, BingoRewardsClaimed = 4001, diff --git a/Maple2.Model/Game/Cube/ConfigurableCube.cs b/Maple2.Model/Game/Cube/ConfigurableCube.cs index 45b5018b3..1aee136f6 100644 --- a/Maple2.Model/Game/Cube/ConfigurableCube.cs +++ b/Maple2.Model/Game/Cube/ConfigurableCube.cs @@ -39,3 +39,14 @@ public void WriteTo(IByteWriter writer) { writer.WriteByte(Distance); } } + + +public class CubeTriggerSettings : IByteSerializable { + public string ScriptXml { get; set; } = string.Empty; + public byte Distance { get; set; } = 1; + + public void WriteTo(IByteWriter writer) { + writer.WriteUnicodeString(ScriptXml); + writer.WriteByte(Distance); + } +} diff --git a/Maple2.Model/Game/Cube/InteractCube.cs b/Maple2.Model/Game/Cube/InteractCube.cs index 9137b29ac..6fcc80e3d 100644 --- a/Maple2.Model/Game/Cube/InteractCube.cs +++ b/Maple2.Model/Game/Cube/InteractCube.cs @@ -16,8 +16,11 @@ public class InteractCube : IByteSerializable { public Nurturing? Nurturing { get; set; } public CubePortalSettings? PortalSettings { get; set; } public CubeNoticeSettings? NoticeSettings { get; set; } + public CubeTriggerSettings? TriggerSettings { get; set; } public long InteractingCharacterId { get; set; } + // Runtime-only helper used by housing function furniture that materializes NPCs. + public int SpawnedNpcObjectId { get; set; } public InteractCube(Vector3B position, FunctionCubeMetadata metadata) { Id = $"4_{position.ConvertToInt()}"; @@ -43,6 +46,7 @@ public InteractCube(string id, FunctionCubeMetadata metadata, CubePortalSettings State = metadata.DefaultState; PortalSettings = portalSettings; NoticeSettings = noticeSettings; + TriggerSettings = null; } public void WriteTo(IByteWriter writer) { diff --git a/Maple2.Model/Game/Dungeon/DungeonUserRecord.cs b/Maple2.Model/Game/Dungeon/DungeonUserRecord.cs index cc8b49eb9..87814dfbd 100644 --- a/Maple2.Model/Game/Dungeon/DungeonUserRecord.cs +++ b/Maple2.Model/Game/Dungeon/DungeonUserRecord.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using Maple2.Model.Enum; using Maple2.PacketLib.Tools; +using Maple2.Tools.Extensions; namespace Maple2.Model.Game.Dungeon; @@ -86,5 +87,23 @@ public void WriteTo(IByteWriter writer) { writer.WriteBool(item.Unknown2); writer.WriteBool(item.Unknown3); } + + // Party meter / performance details. + // Keep this block after the reward data to preserve the packet layout that already works + // for the personal reward/result UI, while still exposing dungeon accumulation records + // to clients that read the extended statistics section. + writer.WriteInt(AccumulationRecords.Count); + foreach ((DungeonAccumulationRecordType type, int value) in AccumulationRecords.OrderBy(entry => (int) entry.Key)) { + writer.Write(type); + writer.WriteInt(value); + } + + writer.WriteInt(Missions.Count); + foreach (DungeonMission mission in Missions.Values.OrderBy(mission => mission.Id)) { + writer.WriteClass(mission); + } + + writer.Write(Flag); + writer.WriteInt(Round); } } diff --git a/Maple2.Model/Game/Market/SoldUgcMarketItem.cs b/Maple2.Model/Game/Market/SoldUgcMarketItem.cs index b8d22bb6a..0b14e1795 100644 --- a/Maple2.Model/Game/Market/SoldUgcMarketItem.cs +++ b/Maple2.Model/Game/Market/SoldUgcMarketItem.cs @@ -10,21 +10,25 @@ public class SoldUgcMarketItem : IByteSerializable { public required string Name { get; init; } public long SoldTime { get; init; } public long AccountId { get; init; } - + public int Count { get; init; } = 1; public void WriteTo(IByteWriter writer) { writer.WriteLong(Id); - writer.WriteLong(); + writer.WriteLong(); writer.WriteUnicodeString(Name); - writer.WriteInt(); - writer.WriteInt(); - writer.WriteLong(); - writer.WriteLong(); - writer.WriteUnicodeString(); - writer.WriteUnicodeString(); - writer.WriteInt(); + + writer.WriteInt(Count); + writer.WriteInt(); + + writer.WriteLong(); + writer.WriteLong(); + writer.WriteUnicodeString(); + writer.WriteUnicodeString(); + writer.WriteInt(); + writer.WriteLong(Price); writer.WriteLong(SoldTime); writer.WriteLong(Profit); } } + diff --git a/Maple2.Model/Game/Shop/RestrictedBuyData.cs b/Maple2.Model/Game/Shop/RestrictedBuyData.cs index 6690f24b4..8edbc8692 100644 --- a/Maple2.Model/Game/Shop/RestrictedBuyData.cs +++ b/Maple2.Model/Game/Shop/RestrictedBuyData.cs @@ -49,9 +49,11 @@ public readonly struct BuyTimeOfDay { public int EndTimeOfDay { get; } // time end in seconds. ex 10600 = 2:56 AM [JsonConstructor] - public BuyTimeOfDay(int startTime, int endTime) { - StartTimeOfDay = startTime; - EndTimeOfDay = endTime; + // System.Text.Json requires every parameter on the [JsonConstructor] to bind to a + // property/field on the type. Parameter names must match property names (case-insensitive). + public BuyTimeOfDay(int startTimeOfDay, int endTimeOfDay) { + StartTimeOfDay = startTimeOfDay; + EndTimeOfDay = endTimeOfDay; } public BuyTimeOfDay Clone() { diff --git a/Maple2.Model/Metadata/Constants.cs b/Maple2.Model/Metadata/Constants.cs index 43c74bf53..f5085e784 100644 --- a/Maple2.Model/Metadata/Constants.cs +++ b/Maple2.Model/Metadata/Constants.cs @@ -123,7 +123,7 @@ public static class Constant { public const bool DebugTriggers = false; // Set to true to enable debug triggers. (It'll write triggers to files and load triggers from files instead of DB) - public const bool AllowUnicodeInNames = false; // Allow Unicode characters in character and guild names + public const bool AllowUnicodeInNames = true; // Allow Unicode characters in character and guild names public static IReadOnlyDictionary ContentRewards { get; } = new Dictionary { { "miniGame", 1005 }, diff --git a/Maple2.Server.Core/Formulas/BonusAttack.cs b/Maple2.Server.Core/Formulas/BonusAttack.cs index d8e433776..0f287425e 100644 --- a/Maple2.Server.Core/Formulas/BonusAttack.cs +++ b/Maple2.Server.Core/Formulas/BonusAttack.cs @@ -9,11 +9,10 @@ public static double Coefficient(int rightHandRarity, int leftHandRarity, JobCod } double weaponBonusAttackCoefficient = RarityMultiplier(rightHandRarity); - if (leftHandRarity == 0) { - return weaponBonusAttackCoefficient; + if (leftHandRarity > 0) { + weaponBonusAttackCoefficient = 0.5 * (weaponBonusAttackCoefficient + RarityMultiplier(leftHandRarity)); } - weaponBonusAttackCoefficient = 0.5 * (weaponBonusAttackCoefficient + RarityMultiplier(leftHandRarity)); return 4.96 * weaponBonusAttackCoefficient * JobBonusMultiplier(jobCode); } diff --git a/Maple2.Server.Core/Network/Session.cs b/Maple2.Server.Core/Network/Session.cs index e1ea5e78c..554a4cc41 100644 --- a/Maple2.Server.Core/Network/Session.cs +++ b/Maple2.Server.Core/Network/Session.cs @@ -37,7 +37,6 @@ public abstract class Session : IDisposable { private bool disposed; private int disconnecting; // 0 = not disconnecting, 1 = disconnect in progress/already triggered (reentrancy guard) - private volatile bool sendFailed; // set on first SendRaw failure to stop send queue drain private readonly uint siv; private readonly uint riv; @@ -336,7 +335,6 @@ private void SendRaw(ByteWriter packet) { // Use async write with timeout to prevent indefinite blocking Task writeTask = networkStream.WriteAsync(packet.Buffer, 0, packet.Length); if (!writeTask.Wait(SEND_TIMEOUT_MS)) { - sendFailed = true; Logger.Warning("SendRaw timeout after {Timeout}ms, disconnecting account={AccountId} char={CharacterId}", SEND_TIMEOUT_MS, AccountId, CharacterId); @@ -358,12 +356,10 @@ private void SendRaw(ByteWriter packet) { throw writeTask.Exception?.GetBaseException() ?? new Exception("Write task faulted"); } } catch (Exception ex) when (ex.InnerException is IOException or SocketException || ex is IOException or SocketException) { - // Connection was closed by the client or is no longer valid - sendFailed = true; + // Expected when client closes the connection (e.g., during migration) Logger.Debug("SendRaw connection closed account={AccountId} char={CharacterId}", AccountId, CharacterId); Disconnect(); } catch (Exception ex) { - sendFailed = true; Logger.Warning(ex, "[LIFECYCLE] SendRaw write failed account={AccountId} char={CharacterId}", AccountId, CharacterId); Disconnect(); } @@ -372,7 +368,7 @@ private void SendRaw(ByteWriter packet) { private void SendWorker() { try { foreach ((byte[] packet, int length) in sendQueue.GetConsumingEnumerable()) { - if (disposed || sendFailed) break; + if (disposed) break; // Encrypt outside lock, then send with timeout PoolByteWriter encryptedPacket; diff --git a/Maple2.Server.Game/Config/MushkingPassConfig.cs b/Maple2.Server.Game/Config/MushkingPassConfig.cs new file mode 100644 index 000000000..f1541c3ea --- /dev/null +++ b/Maple2.Server.Game/Config/MushkingPassConfig.cs @@ -0,0 +1,43 @@ +using System.Text.Json.Serialization; + +namespace Maple2.Server.Game.Config; + +public sealed class MushkingPassConfig { + public bool Enabled { get; set; } = true; + public string SeasonName { get; set; } = "Pre-Season"; + public DateTime SeasonStartUtc { get; set; } = new(2026, 3, 1, 0, 0, 0, DateTimeKind.Utc); + public DateTime SeasonEndUtc { get; set; } = new(2028, 10, 1, 0, 0, 0, DateTimeKind.Utc); + public int MaxLevel { get; set; } = 30; + public int ExpPerLevel { get; set; } = 100; + public MonsterExpConfig MonsterExp { get; set; } = new(); + public int GoldPassActivationItemId { get; set; } + public int GoldPassActivationItemCount { get; set; } = 1; + public List FreeRewards { get; set; } = []; + public List GoldRewards { get; set; } = []; + + [JsonIgnore] + public IReadOnlyDictionary FreeRewardsByLevel => freeRewardsByLevel ??= FreeRewards + .GroupBy(entry => entry.Level) + .ToDictionary(group => group.Key, group => group.Last()); + + [JsonIgnore] + public IReadOnlyDictionary GoldRewardsByLevel => goldRewardsByLevel ??= GoldRewards + .GroupBy(entry => entry.Level) + .ToDictionary(group => group.Key, group => group.Last()); + + private Dictionary? freeRewardsByLevel; + private Dictionary? goldRewardsByLevel; +} + +public sealed class MonsterExpConfig { + public int Normal { get; set; } = 2; + public int Elite { get; set; } = 8; + public int Boss { get; set; } = 30; +} + +public sealed class PassRewardConfig { + public int Level { get; set; } + public int ItemId { get; set; } + public int Rarity { get; set; } = -1; + public int Amount { get; set; } = 1; +} diff --git a/Maple2.Server.Game/Config/SurvivalPassXmlConfig.cs b/Maple2.Server.Game/Config/SurvivalPassXmlConfig.cs new file mode 100644 index 000000000..743a1cec6 --- /dev/null +++ b/Maple2.Server.Game/Config/SurvivalPassXmlConfig.cs @@ -0,0 +1,193 @@ +using System.Xml.Linq; +using Serilog; + +namespace Maple2.Server.Game.Config; + +public sealed class SurvivalPassXmlConfig { + private static readonly ILogger Logger = Log.Logger.ForContext(); + + public SortedDictionary LevelThresholds { get; } = new SortedDictionary(); + public Dictionary FreeRewards { get; } = new Dictionary(); + public Dictionary PaidRewards { get; } = new Dictionary(); + + public int ActivationItemId { get; private set; } + public int ActivationItemCount { get; private set; } = 1; + public int MonsterKillExp { get; private set; } = 1; + public int EliteKillExp { get; private set; } = 5; + public int BossKillExp { get; private set; } = 20; + public bool AllowDirectActivateWithoutItem { get; private set; } + + public static SurvivalPassXmlConfig Load() { + var config = new SurvivalPassXmlConfig(); + string baseDir = AppContext.BaseDirectory; + + config.LoadServerConfig(FindFile(baseDir, "survivalserverconfig.xml")); + config.LoadLevels(FindFile(baseDir, "survivallevel.xml")); + config.LoadRewards(FindFile(baseDir, "survivalpassreward.xml"), config.FreeRewards); + config.LoadRewards(FindFile(baseDir, "survivalpassreward_paid.xml"), config.PaidRewards); + + if (config.LevelThresholds.Count == 0) { + config.LevelThresholds[1] = 0; + } + + Logger.Information("Loaded survival config thresholds={Thresholds} freeRewards={FreeRewards} paidRewards={PaidRewards} activationItem={ItemId} x{ItemCount}", + config.LevelThresholds.Count, config.FreeRewards.Count, config.PaidRewards.Count, config.ActivationItemId, config.ActivationItemCount); + return config; + } + + private void LoadServerConfig(string path) { + if (!File.Exists(path)) { + return; + } + + XDocument document = XDocument.Load(path); + XElement? node = document.Root == null ? null : document.Root.Element("survivalPassServer"); + if (node == null) { + return; + } + + ActivationItemId = ParseInt(node.Attribute("activationItemId") != null ? node.Attribute("activationItemId")!.Value : null); + ActivationItemCount = Math.Max(1, ParseInt(node.Attribute("activationItemCount") != null ? node.Attribute("activationItemCount")!.Value : null, 1)); + MonsterKillExp = Math.Max(1, ParseInt(node.Attribute("monsterKillExp") != null ? node.Attribute("monsterKillExp")!.Value : null, 1)); + EliteKillExp = Math.Max(1, ParseInt(node.Attribute("eliteKillExp") != null ? node.Attribute("eliteKillExp")!.Value : null, 5)); + BossKillExp = Math.Max(1, ParseInt(node.Attribute("bossKillExp") != null ? node.Attribute("bossKillExp")!.Value : null, 20)); + AllowDirectActivateWithoutItem = ParseBool(node.Attribute("allowDirectActivateWithoutItem") != null ? node.Attribute("allowDirectActivateWithoutItem")!.Value : null, false); + } + + private void LoadLevels(string path) { + if (!File.Exists(path)) { + Logger.Warning("Missing survival level config: {Path}", path); + return; + } + + XDocument document = XDocument.Load(path); + IEnumerable elements = document.Root != null ? document.Root.Elements("survivalLevelExp") : Enumerable.Empty(); + foreach (XElement node in elements) { + if (!IsFeatureMatch(node)) { + continue; + } + int level = ParseInt(node.Attribute("level") != null ? node.Attribute("level")!.Value : null); + long reqExp = ParseLong(node.Attribute("reqExp") != null ? node.Attribute("reqExp")!.Value : null); + if (level <= 0) { + continue; + } + LevelThresholds[level] = reqExp; + } + } + + private void LoadRewards(string path, Dictionary target) { + if (!File.Exists(path)) { + Logger.Warning("Missing survival reward config: {Path}", path); + return; + } + + XDocument document = XDocument.Load(path); + IEnumerable elements = document.Root != null ? document.Root.Elements("survivalPassReward") : Enumerable.Empty(); + foreach (XElement node in elements) { + if (!IsFeatureMatch(node)) { + continue; + } + + int id = ParseInt(node.Attribute("id") != null ? node.Attribute("id")!.Value : null, 1); + if (id != 1) { + continue; + } + + int level = ParseInt(node.Attribute("level") != null ? node.Attribute("level")!.Value : null); + if (level <= 0) { + continue; + } + + var grants = new List(); + AddGrant(node, 1, grants); + AddGrant(node, 2, grants); + if (grants.Count == 0) { + continue; + } + + target[level] = new SurvivalRewardEntry(level, grants.ToArray()); + } + } + + private static bool IsFeatureMatch(XElement node) { + string feature = node.Attribute("feature") != null ? node.Attribute("feature")!.Value : string.Empty; + return string.IsNullOrEmpty(feature) || string.Equals(feature, "SurvivalContents03", StringComparison.OrdinalIgnoreCase); + } + + private static void AddGrant(XElement node, int index, IList grants) { + string type = node.Attribute("type" + index) != null ? node.Attribute("type" + index)!.Value : string.Empty; + if (string.IsNullOrWhiteSpace(type)) { + return; + } + + string idRaw = node.Attribute("id" + index) != null ? node.Attribute("id" + index)!.Value : string.Empty; + string valueRaw = node.Attribute("value" + index) != null ? node.Attribute("value" + index)!.Value : string.Empty; + string countRaw = node.Attribute("count" + index) != null ? node.Attribute("count" + index)!.Value : string.Empty; + + grants.Add(new SurvivalRewardGrant(type.Trim(), idRaw, valueRaw, countRaw)); + } + + private static string FindFile(string baseDir, string fileName) { + string[] candidates = new[] { + Path.Combine(baseDir, fileName), + Path.Combine(baseDir, "config", fileName), + Path.Combine(Directory.GetCurrentDirectory(), fileName), + Path.Combine(Directory.GetCurrentDirectory(), "config", fileName) + }; + foreach (string candidate in candidates) { + if (File.Exists(candidate)) { + return candidate; + } + } + return Path.Combine(baseDir, "config", fileName); + } + + private static int ParseInt(string? value, int fallback = 0) { + int parsed; + return int.TryParse(value, out parsed) ? parsed : fallback; + } + + private static long ParseLong(string? value, long fallback = 0) { + long parsed; + return long.TryParse(value, out parsed) ? parsed : fallback; + } + + private static bool ParseBool(string? value, bool fallback = false) { + if (string.IsNullOrWhiteSpace(value)) { + return fallback; + } + bool parsedBool; + if (bool.TryParse(value, out parsedBool)) { + return parsedBool; + } + int parsedInt; + if (int.TryParse(value, out parsedInt)) { + return parsedInt != 0; + } + return fallback; + } +} + +public sealed class SurvivalRewardEntry { + public int Level { get; private set; } + public SurvivalRewardGrant[] Grants { get; private set; } + + public SurvivalRewardEntry(int level, SurvivalRewardGrant[] grants) { + Level = level; + Grants = grants; + } +} + +public sealed class SurvivalRewardGrant { + public string Type { get; private set; } + public string IdRaw { get; private set; } + public string ValueRaw { get; private set; } + public string CountRaw { get; private set; } + + public SurvivalRewardGrant(string type, string idRaw, string valueRaw, string countRaw) { + Type = type; + IdRaw = idRaw ?? string.Empty; + ValueRaw = valueRaw ?? string.Empty; + CountRaw = countRaw ?? string.Empty; + } +} diff --git a/Maple2.Server.Game/Config/survivallevel.xml b/Maple2.Server.Game/Config/survivallevel.xml new file mode 100644 index 000000000..b9f99126d --- /dev/null +++ b/Maple2.Server.Game/Config/survivallevel.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Maple2.Server.Game/Config/survivalpassreward.xml b/Maple2.Server.Game/Config/survivalpassreward.xml new file mode 100644 index 000000000..f83cdd8d2 --- /dev/null +++ b/Maple2.Server.Game/Config/survivalpassreward.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Maple2.Server.Game/Config/survivalpassreward_paid.xml b/Maple2.Server.Game/Config/survivalpassreward_paid.xml new file mode 100644 index 000000000..f1995a507 --- /dev/null +++ b/Maple2.Server.Game/Config/survivalpassreward_paid.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Maple2.Server.Game/Config/survivalserverconfig.xml b/Maple2.Server.Game/Config/survivalserverconfig.xml new file mode 100644 index 000000000..dc1ea1cbc --- /dev/null +++ b/Maple2.Server.Game/Config/survivalserverconfig.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Maple2.Server.Game/GameServer.cs b/Maple2.Server.Game/GameServer.cs index 7d2524626..b09bc53f9 100644 --- a/Maple2.Server.Game/GameServer.cs +++ b/Maple2.Server.Game/GameServer.cs @@ -66,17 +66,32 @@ public GameServer(FieldManager.Factory fieldFactory, PacketRouter r public override void OnConnected(GameSession session) { lock (mutex) { connectingSessions.Remove(session); - sessions[session.CharacterId] = session; + + if (session.CharacterId != 0) { + sessions[session.CharacterId] = session; + } + + // 可选:避免残留的 0 键占位 + if (sessions.TryGetValue(0, out GameSession? zero) && ReferenceEquals(zero, session)) { + sessions.Remove(0); + } } } - public override void OnDisconnected(GameSession session) { lock (mutex) { connectingSessions.Remove(session); - sessions.Remove(session.CharacterId); + + long cid = session.CharacterId; + if (cid == 0) return; + + // 关键:只允许“当前登记在 sessions 里的那个会话”删除自己 + // 迁移/换图时会出现:新会话先注册,旧会话后断开 + // 如果这里无条件 Remove,会把新会话也删掉 => Heartbeat unknown => 假离线 + if (sessions.TryGetValue(cid, out GameSession? current) && ReferenceEquals(current, session)) { + sessions.Remove(cid); + } } } - public bool GetSession(long characterId, [NotNullWhen(true)] out GameSession? session) { lock (mutex) { return sessions.TryGetValue(characterId, out session); diff --git a/Maple2.Server.Game/Manager/BuffManager.cs b/Maple2.Server.Game/Manager/BuffManager.cs index f9e378c22..792535650 100644 --- a/Maple2.Server.Game/Manager/BuffManager.cs +++ b/Maple2.Server.Game/Manager/BuffManager.cs @@ -4,6 +4,7 @@ using Maple2.Model.Metadata; using Maple2.Server.Game.Model; using Maple2.Server.Game.Model.Skill; +using Maple2.Server.Game.Manager.Field; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Util; using Maple2.Server.World.Service; @@ -40,7 +41,7 @@ public BuffManager(IActor actor) { public void Initialize() { // Load buffs that are not broadcasted to the field if (Actor is FieldPlayer player) { - player.Session.Config.Skill.UpdatePassiveBuffs(false); + player.Session.Config.Skill.UpdatePassiveBuffs(false, false); player.Stats.Refresh(); } } @@ -432,7 +433,11 @@ public void LeaveField() { foreach (MapEntranceBuff buff in Actor.Field.Metadata.EntranceBuffs) { buffsToRemove.Add((buff.Id, Actor.ObjectId)); } + bool leavingHome = Actor.Field is HomeFieldManager; foreach (Buff buff in EnumerateBuffs().Where(b => b.Metadata.Property.RemoveOnLeaveField)) { + if (leavingHome && HousingFunctionFurnitureRegistry.IsHousingCharmBuff(buff.Id)) { + continue; + } buffsToRemove.Add((buff.Id, Actor.ObjectId)); } Remove(buffsToRemove.ToArray()); diff --git a/Maple2.Server.Game/Manager/Config/SkillInfo.cs b/Maple2.Server.Game/Manager/Config/SkillInfo.cs index 72c224e27..570f34098 100644 --- a/Maple2.Server.Game/Manager/Config/SkillInfo.cs +++ b/Maple2.Server.Game/Manager/Config/SkillInfo.cs @@ -192,6 +192,29 @@ public IEnumerable GetSkills(SkillType type, SkillRank rank) { return null; } + public Skill? GetSkill(int skillId, SkillRank rank = SkillRank.Both) { + Skill? mainSkill = GetMainSkill(skillId, rank); + if (mainSkill != null) { + return mainSkill; + } + + for (int i = 0; i < SKILL_TYPES; i++) { + if (rank is SkillRank.Basic or SkillRank.Both) { + if (SubSkills[i, (int) SkillRank.Basic].TryGetValue(skillId, out Skill? skill)) { + return skill; + } + } + + if (rank is SkillRank.Awakening or SkillRank.Both) { + if (SubSkills[i, (int) SkillRank.Awakening].TryGetValue(skillId, out Skill? skill)) { + return skill; + } + } + } + + return null; + } + public void WriteTo(IByteWriter writer) { writer.WriteInt((int) Job); writer.WriteByte(1); // Count diff --git a/Maple2.Server.Game/Manager/ConfigManager.cs b/Maple2.Server.Game/Manager/ConfigManager.cs index 84a83e35b..74094e4cc 100644 --- a/Maple2.Server.Game/Manager/ConfigManager.cs +++ b/Maple2.Server.Game/Manager/ConfigManager.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Maple2.Database.Extensions; using Maple2.Database.Storage; +using Maple2.Model; using Maple2.Model.Enum; using Maple2.Model.Game; using Maple2.Model.Metadata; @@ -453,25 +454,88 @@ public void ResetStatPoints() { session.Send(NoticePacket.Message("s_char_info_reset_stat_pointsuccess_msg")); } + public void ReapplyAllocatedStats(bool send = false) { + foreach (BasicAttribute type in statAttributes.Allocation.Attributes) { + int points = statAttributes.Allocation[type]; + if (points <= 0) { + continue; + } + + UpdateStatAttribute(type, points, send); + } + } + private void UpdateStatAttribute(BasicAttribute type, int points, bool send = true) { + var values = session.Player.Stats.Values; + long oldStr = values[BasicAttribute.Strength].Total; + long oldDex = values[BasicAttribute.Dexterity].Total; + long oldInt = values[BasicAttribute.Intelligence].Total; + long oldLuk = values[BasicAttribute.Luck].Total; + JobCode jobCode = session.Player.Value.Character.Job.Code(); + switch (type) { case BasicAttribute.Strength: case BasicAttribute.Dexterity: case BasicAttribute.Intelligence: case BasicAttribute.Luck: - session.Player.Stats.Values[type].AddTotal(1 * points); + values[type].AddTotal(points); break; case BasicAttribute.Health: - session.Player.Stats.Values[BasicAttribute.Health].AddTotal(10 * points); + values[BasicAttribute.Health].AddTotal(10L * points); break; case BasicAttribute.CriticalRate: - session.Player.Stats.Values[BasicAttribute.CriticalRate].AddTotal(3 * points); + values[BasicAttribute.CriticalRate].AddTotal(3L * points); + break; + } + + long newStr = values[BasicAttribute.Strength].Total; + long newDex = values[BasicAttribute.Dexterity].Total; + long newInt = values[BasicAttribute.Intelligence].Total; + long newLuk = values[BasicAttribute.Luck].Total; + + long oldPhysicalAtk = Maple2.Server.Core.Formulas.AttackStat.PhysicalAtk(jobCode, oldStr, oldDex, oldLuk); + long newPhysicalAtk = Maple2.Server.Core.Formulas.AttackStat.PhysicalAtk(jobCode, newStr, newDex, newLuk); + long oldMagicalAtk = Maple2.Server.Core.Formulas.AttackStat.MagicalAtk(jobCode, oldInt); + long newMagicalAtk = Maple2.Server.Core.Formulas.AttackStat.MagicalAtk(jobCode, newInt); + + long physicalAtkDelta = newPhysicalAtk - oldPhysicalAtk; + long magicalAtkDelta = newMagicalAtk - oldMagicalAtk; + if (physicalAtkDelta != 0) { + values[BasicAttribute.PhysicalAtk].AddTotal(physicalAtkDelta); + } + if (magicalAtkDelta != 0) { + values[BasicAttribute.MagicalAtk].AddTotal(magicalAtkDelta); + } + + switch (type) { + case BasicAttribute.Strength: + values[BasicAttribute.PhysicalRes].AddTotal(points); + break; + case BasicAttribute.Dexterity: + values[BasicAttribute.PhysicalRes].AddTotal(points); + values[BasicAttribute.Accuracy].AddTotal(points); + break; + case BasicAttribute.Intelligence: + values[BasicAttribute.MagicalRes].AddTotal(points); + break; + case BasicAttribute.Luck: + values[BasicAttribute.CriticalRate].AddTotal(points); break; } - // Sends packet to notify client, skipped during loading. if (send) { - session.Send(StatsPacket.Update(session.Player, type)); + session.Send(StatsPacket.Update(session.Player, + BasicAttribute.Strength, + BasicAttribute.Dexterity, + BasicAttribute.Intelligence, + BasicAttribute.Luck, + BasicAttribute.Health, + BasicAttribute.CriticalRate, + BasicAttribute.PhysicalAtk, + BasicAttribute.MagicalAtk, + BasicAttribute.PhysicalRes, + BasicAttribute.MagicalRes, + BasicAttribute.Accuracy)); } } #endregion diff --git a/Maple2.Server.Game/Manager/DungeonManager.cs b/Maple2.Server.Game/Manager/DungeonManager.cs index 98d8dcfe4..5b47c2204 100644 --- a/Maple2.Server.Game/Manager/DungeonManager.cs +++ b/Maple2.Server.Game/Manager/DungeonManager.cs @@ -249,7 +249,9 @@ public void SetDungeon(DungeonFieldManager field) { Lobby = field; LobbyRoomId = field.RoomId; Metadata = field.DungeonMetadata; - UserRecord = new DungeonUserRecord(field.DungeonId, session.CharacterId); + UserRecord = new DungeonUserRecord(field.DungeonId, session.CharacterId) { + WithParty = field.Size > 1 || field.PartyId != 0, + }; foreach (int missionId in Metadata.UserMissions) { //TODO @@ -437,7 +439,7 @@ void GetClearDungeonRewards() { ICollection items = []; if (rewardMetadata.UnlimitedDropBoxIds.Length > 0) { foreach (int boxId in rewardMetadata.UnlimitedDropBoxIds) { - items = items.Concat(Lobby.ItemDrop.GetIndividualDropItems(boxId)).ToList(); + items = items.Concat(Lobby.ItemDrop.GetIndividualDropItems(session, session.Player.Value.Character.Level, boxId)).ToList(); items = items.Concat(Lobby.ItemDrop.GetGlobalDropItems(boxId, Metadata.Level)).ToList(); } } diff --git a/Maple2.Server.Game/Manager/Field/AgentNavigation.cs b/Maple2.Server.Game/Manager/Field/AgentNavigation.cs index 8703ee615..93a8fff78 100644 --- a/Maple2.Server.Game/Manager/Field/AgentNavigation.cs +++ b/Maple2.Server.Game/Manager/Field/AgentNavigation.cs @@ -21,6 +21,7 @@ public sealed class AgentNavigation { public List? currentPath = []; private int currentPathIndex = 0; private float currentPathProgress = 0; + private bool currentPathIsFallback = false; public AgentNavigation(FieldNpc fieldNpc, DtCrowdAgent dtAgent, DtCrowd dtCrowd) { npc = fieldNpc; @@ -158,6 +159,10 @@ public AgentNavigation(FieldNpc fieldNpc, DtCrowdAgent dtAgent, DtCrowd dtCrowd) public bool HasPath => currentPath != null && currentPath.Count > 0; + private static bool IsVayarianGuardian(int npcId) { + return npcId is 21500420 or 21500422 or 21500423 or 21500424; + } + public bool UpdatePosition() { if (!field.FindNearestPoly(npc.Position, out _, out RcVec3f position)) { Logger.Error("Failed to find valid position from {Source} => {Position}", npc.Position, position); @@ -216,6 +221,18 @@ public Vector3 GetAgentPosition() { currentPathProgress += (float) timeSpan.TotalSeconds * speed / distance; RcVec3f end = RcVec3f.Lerp(currentPath[currentPathIndex], currentPath[currentPathIndex + 1], currentPathProgress); + if (currentPathIsFallback) { + // Keep the end point on the navmesh each tick; prevents straight-line fallback from pushing NPCs through walls/out of bounds. + if (field.FindNearestPoly(end, out _, out RcVec3f snappedEnd)) { + end = snappedEnd; + if (currentPath is not null && currentPathIndex + 1 < currentPath.Count) { + currentPath[currentPathIndex + 1] = snappedEnd; + } + } else { + // Can't stay on mesh; stop movement. + currentPath = null; + } + } return (start, DotRecastHelper.FromNavMeshSpace(end)); } @@ -294,14 +311,45 @@ public bool PathTo(Vector3 goal) { private bool SetPathTo(RcVec3f target) { currentPath = []; currentPathIndex = 0; + currentPathIsFallback = false; currentPathProgress = 0; + try { - currentPath = FindPath(agent.npos, target); + List? path = FindPath(agent.npos, target); + if (path is null) { + // Clamp target to nearest navmesh polygon to avoid drifting outside walkable area. + if (!field.FindNearestPoly(target, out _, out RcVec3f snappedTarget)) { + Logger.Warning("[Fallback] Could not clamp target to navmesh. Target(nav)={Target}", target); + return false; + } + target = snappedTarget; + Vector3 worldTarget = DotRecastHelper.FromNavMeshSpace(target); + + if (IsVayarianGuardian(npc.Value.Id)) { + // Scripted NPC jump/warp: advance trigger even when navmesh has no valid path. + Logger.Warning("No path for Vayarian Guardian {NpcId}; snapping from {From} to {To}", npc.Value.Id, npc.Position, worldTarget); + UpdatePosition(worldTarget); + currentPath = null; + return true; + } + + Logger.Warning("Failed to find path from {FromNav} to {ToNav}; fallback: clamped straight-line on navmesh. World(from)={FromWorld} World(to)={ToWorld}", agent.npos, target, DotRecastHelper.FromNavMeshSpace(agent.npos), worldTarget); + currentPath = [agent.npos, target]; + return true; + } + + currentPath = path; + return true; } catch (Exception ex) { Logger.Error(ex, "Failed to find path to {Target}", target); + return false; } - - return currentPath is not null; + } + public void ClearPath() { + currentPath?.Clear(); + currentPathIndex = 0; + currentPathProgress = 0; + currentPathIsFallback = false; } public bool PathAwayFrom(Vector3 goal, int distance) { @@ -315,11 +363,20 @@ public bool PathAwayFrom(Vector3 goal, int distance) { // get distance in navmesh space float fDistance = distance * DotRecastHelper.MapRotation.GetRightAxis().Length(); - // get direction from agent to target - RcVec3f direction = RcVec3f.Normalize(RcVec3f.Subtract(target, position)); + // get direction from agent to target (guard against zero-length vectors which would produce NaNs) + RcVec3f delta = RcVec3f.Subtract(target, position); + float lenSqr = RcVec3f.Dot(delta, delta); + RcVec3f direction; + if (lenSqr > 1e-6f) { + direction = RcVec3f.Normalize(delta); + } else { + // If goal overlaps agent (common during melee hit / on-hit stepback), choose a stable fallback direction. + // Alternate per-object id so mobs don't all backstep the same way. + direction = (npc.ObjectId & 1) == 0 ? new RcVec3f(1, 0, 0) : new RcVec3f(-1, 0, 0); + } - // get the point that is fDistance away from the target in the opposite direction - RcVec3f positionAway = RcVec3f.Add(position, RcVec3f.Normalize(direction) * -fDistance); + // get the point that is fDistance away from the agent in the opposite direction + RcVec3f positionAway = RcVec3f.Add(position, direction * -fDistance); // find the nearest poly to the positionAway if (!field.FindNearestPoly(positionAway, out _, out RcVec3f positionAwayNavMesh)) { @@ -328,17 +385,35 @@ public bool PathAwayFrom(Vector3 goal, int distance) { return SetPathAway(positionAwayNavMesh); } - private bool SetPathAway(RcVec3f target) { currentPath = []; currentPathIndex = 0; + currentPathIsFallback = false; currentPathProgress = 0; + try { - currentPath = FindPath(agent.npos, target); + List? path = FindPath(agent.npos, target); + if (path is null) { + Vector3 worldTarget = DotRecastHelper.FromNavMeshSpace(target); + + if (IsVayarianGuardian(npc.Value.Id)) { + // Scripted NPC jump/warp: advance trigger even when navmesh has no valid path. + Logger.Warning("No path for Vayarian Guardian {NpcId}; snapping from {From} to {To}", npc.Value.Id, npc.Position, worldTarget); + UpdatePosition(worldTarget); + currentPath = null; + return true; + } + + Logger.Warning("Failed to find path from {FromNav} to {ToNav}; fallback: clamped straight-line on navmesh. World(from)={FromWorld} World(to)={ToWorld}", agent.npos, target, DotRecastHelper.FromNavMeshSpace(agent.npos), worldTarget); + currentPath = [agent.npos, target]; + return true; + } + + currentPath = path; + return true; } catch (Exception ex) { - Logger.Error(ex, "Failed to find path away from {Target}", target); + Logger.Error(ex, "Failed to find path to {Target}", target); + return false; } - - return currentPath is not null; } } diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/DungeonFieldManager.cs b/Maple2.Server.Game/Manager/Field/FieldManager/DungeonFieldManager.cs index 5b3d92441..cfc967784 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/DungeonFieldManager.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/DungeonFieldManager.cs @@ -33,25 +33,15 @@ public void ChangeState(DungeonState state) { var compiledResults = new Dictionary(); - // Get player's best record + // Party meter should compare the same metric across all party members. + // Use TotalDamage for everyone instead of assigning each character a different “best” category. foreach ((long characterId, DungeonUserRecord userRecord) in DungeonRoomRecord.UserResults) { - if (compiledResults.ContainsKey(characterId)) { - continue; - } - if (!TryGetPlayerById(characterId, out FieldPlayer? _)) { continue; } - List> recordkvp = userRecord.AccumulationRecords.OrderByDescending(record => record.Value).ToList(); - foreach (KeyValuePair kvp in recordkvp) { - if (compiledResults.ContainsKey(characterId) || compiledResults.Values.Any(x => x.RecordType == kvp.Key)) { - continue; - } - - compiledResults.Add(characterId, new DungeonUserResult(characterId, kvp.Key, kvp.Value + 1)); - break; - } + userRecord.AccumulationRecords.TryGetValue(DungeonAccumulationRecordType.TotalDamage, out int totalDamage); + compiledResults[characterId] = new DungeonUserResult(characterId, DungeonAccumulationRecordType.TotalDamage, totalDamage); } Broadcast(DungeonRoomPacket.DungeonResult(DungeonRoomRecord.State, compiledResults)); diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs index a68abcf28..69c7afded 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs @@ -46,6 +46,11 @@ public partial class FieldManager { private readonly ConcurrentDictionary fieldItems = new(); private readonly ConcurrentDictionary fieldMobSpawns = new(); private readonly ConcurrentDictionary fieldSpawnPointNpcs = new(); + + + // Tracks spawnPointIds that have died and may have been removed from Mobs/Npcs dictionaries. + // This allows triggers like MonsterDead(spawnId) to continue working even after the dead NPC is despawned. + private readonly ConcurrentDictionary deadSpawnPoints = new(); private readonly ConcurrentDictionary fieldPlayerSpawnPoints = new(); private readonly ConcurrentDictionary fieldSpawnGroups = new(); private readonly ConcurrentDictionary fieldSkills = new(); @@ -115,6 +120,11 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId } public FieldNpc? SpawnNpc(NpcMetadata npc, Vector3 position, Vector3 rotation, bool disableAi = false, FieldMobSpawn? owner = null, SpawnPointNPC? spawnPointNpc = null, string spawnAnimation = "") { + + if (spawnPointNpc is not null) { + deadSpawnPoints.TryRemove(spawnPointNpc.SpawnPointId, out _); + } + // Apply random offset if SpawnRadius is set Vector3 spawnPosition = position; if (spawnPointNpc?.SpawnRadius > 0) { @@ -181,17 +191,92 @@ public FieldPlayer SpawnPlayer(GameSession session, Player player, int portalId int objectId = player != null ? NextGlobalId() : NextLocalId(); AnimationMetadata? animation = NpcMetadata.GetAnimation(npc.Model.Name); - var fieldPet = new FieldPet(this, objectId, agent, new Npc(npc, animation), pet, petMetadata, Constant.PetFieldAiPath, player) { + string aiPath = Constant.PetFieldAiPath; + if (player != null) { + string customBattleAiPath = $"Pet/AI_{pet.Id}.xml"; + aiPath = AiMetadata.TryGet(customBattleAiPath, out _) ? customBattleAiPath : "Pet/AI_DefaultPetBattle.xml"; + } + + var fieldPet = new FieldPet(this, objectId, agent, new Npc(npc, animation), pet, petMetadata, aiPath, player) { Owner = owner, Position = position, Rotation = rotation, Origin = owner?.Position ?? position, }; + if (player != null) { + ApplyOwnedPetCombatStats(fieldPet); + } + Pets[fieldPet.ObjectId] = fieldPet; return fieldPet; } + private static void ApplyOwnedPetCombatStats(FieldPet fieldPet) { + Stats stats = fieldPet.Stats.Values; + Item petItem = fieldPet.Pet; + int petLevel = Math.Max(1, (int) (petItem.Pet?.Level ?? (short) 1)); + int petRarity = Math.Max(1, petItem.Rarity); + + double levelScale = 1d + ((petLevel - 1) * 0.08d); + double rarityScale = 1d + ((petRarity - 1) * 0.18d); + double combinedScale = Math.Max(1d, levelScale * rarityScale); + + BoostBase(BasicAttribute.PhysicalAtk, 0.90d); + BoostBase(BasicAttribute.MagicalAtk, 0.90d); + BoostBase(BasicAttribute.MinWeaponAtk, 0.90d); + BoostBase(BasicAttribute.MaxWeaponAtk, 0.90d); + BoostBase(BasicAttribute.Accuracy, 0.35d); + BoostBase(BasicAttribute.CriticalRate, 0.30d); + BoostBase(BasicAttribute.Defense, 0.35d); + BoostBase(BasicAttribute.PhysicalRes, 0.35d); + BoostBase(BasicAttribute.MagicalRes, 0.35d); + BoostBase(BasicAttribute.Health, 0.75d); + + if (petItem.Stats != null) { + foreach (ItemStats.Type type in Enum.GetValues()) { + ApplyPetItemOption(stats, petItem.Stats[type]); + } + } + + stats.Total(); + + if (stats[BasicAttribute.MinWeaponAtk].Total <= 0) { + long fallbackWeapon = Math.Max(1L, (long) Math.Round(Math.Max(stats[BasicAttribute.PhysicalAtk].Total, stats[BasicAttribute.MagicalAtk].Total) * 0.4d)); + stats[BasicAttribute.MinWeaponAtk].AddTotal(fallbackWeapon); + } + + if (stats[BasicAttribute.MaxWeaponAtk].Total <= stats[BasicAttribute.MinWeaponAtk].Total) { + long fallbackSpread = Math.Max(1L, (long) Math.Round(Math.Max(stats[BasicAttribute.PhysicalAtk].Total, stats[BasicAttribute.MagicalAtk].Total) * 0.2d)); + stats[BasicAttribute.MaxWeaponAtk].AddTotal(stats[BasicAttribute.MinWeaponAtk].Total + fallbackSpread - stats[BasicAttribute.MaxWeaponAtk].Total); + } + + stats[BasicAttribute.Health].Current = stats[BasicAttribute.Health].Total; + return; + + void BoostBase(BasicAttribute attribute, double factor) { + long baseValue = stats[attribute].Base; + if (baseValue <= 0) { + return; + } + + long bonus = (long) Math.Round(baseValue * (combinedScale - 1d) * factor); + if (bonus > 0) { + stats[attribute].AddTotal(bonus); + } + } + } + + private static void ApplyPetItemOption(Stats stats, ItemStats.Option option) { + foreach ((BasicAttribute attribute, BasicOption value) in option.Basic) { + stats[attribute].AddTotal(value); + } + + foreach ((SpecialAttribute attribute, SpecialOption value) in option.Special) { + stats[attribute].AddTotal(value); + } + } + public FieldPortal SpawnPortal(Portal portal, Vector3 position = default, Vector3 rotation = default) { var fieldPortal = new FieldPortal(this, NextLocalId(), portal) { Position = position != default ? position : portal.Position, @@ -525,6 +610,8 @@ public void DespawnWorldBoss() { RemoveNpc(objectId); } + public bool IsSpawnPointDead(int spawnId) => deadSpawnPoints.ContainsKey(spawnId); + public void ToggleNpcSpawnPoint(int spawnId) { List spawns = fieldSpawnPointNpcs.Values.Where(spawn => spawn.Value.SpawnPointId == spawnId).ToList(); foreach (FieldSpawnPointNpc spawn in spawns) { @@ -847,6 +934,7 @@ public FieldPortal SpawnEventPortal(FieldPlayer player, int fieldId, int portalD } public FieldFunctionInteract? AddFieldFunctionInteract(PlotCube cube) { + HousingFunctionFurnitureRegistry.EnsureInteract(cube, FunctionCubeMetadata); if (cube.Interact == null) { return null; } @@ -858,6 +946,7 @@ public FieldPortal SpawnEventPortal(FieldPlayer player, int fieldId, int portalD fieldFunctionInteracts[cube.Interact.Id] = fieldInteract; Broadcast(FunctionCubePacket.AddFunctionCube(cube.Interact)); + HousingFunctionFurnitureRegistry.Materialize(this, cube); return fieldInteract; } @@ -951,6 +1040,10 @@ public bool RemoveNpc(int objectId, TimeSpan removeDelay = default) { } Broadcast(FieldPacket.RemoveNpc(objectId)); Broadcast(ProxyObjectPacket.RemoveNpc(objectId)); + + if (npc.IsDead && npc.SpawnPointId != 0) { + deadSpawnPoints.TryAdd(npc.SpawnPointId, 1); + } npc.Dispose(); }, removeDelay); return true; @@ -1018,7 +1111,7 @@ public void OnAddPlayer(FieldPlayer added) { foreach (Plot plot in Plots.Values) { foreach (PlotCube plotCube in plot.Cubes.Values) { - if (plotCube.Interact?.NoticeSettings is not null) { + if (plotCube.Interact?.NoticeSettings is not null && !HousingFunctionFurnitureRegistry.IsSmartComputer(plotCube)) { added.Session.Send(HomeActionPacket.SendCubeNoticeSettings(plotCube)); } } diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.Trigger.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.Trigger.cs index 1eec257bd..8f18bace3 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.Trigger.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.Trigger.cs @@ -32,6 +32,20 @@ public partial class FieldManager { } } + public FieldTrigger? AddTrigger(TriggerModel trigger, Trigger.Helpers.Trigger parsedTrigger) { + try { + var fieldTrigger = new FieldTrigger(this, NextLocalId(), trigger, parsedTrigger) { + Position = trigger.Position, + Rotation = trigger.Rotation, + }; + fieldTriggers[trigger.Name] = fieldTrigger; + return fieldTrigger; + } catch (ArgumentException ex) { + logger.Warning(ex, "Invalid Trigger: {Exception}", ex.Message); + return null; + } + } + public ICollection EnumerateTrigger() => fieldTriggers.Values; public bool TryGetTrigger(string name, [NotNullWhen(true)] out FieldTrigger? fieldTrigger) { diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs index 4e6cd94fc..ec16040fa 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs @@ -139,7 +139,6 @@ public virtual void Init() { Plots[plot.Number] = plot; plot.Cubes.Values - .Where(plotCube => plotCube.Interact != null) .ToList() .ForEach(plotCube => AddFieldFunctionInteract(plotCube)); @@ -391,13 +390,30 @@ public bool FindNearestPoly(RcVec3f point, out long nearestRef, out RcVec3f posi return false; } - DtStatus status = Navigation.Crowd.GetNavMeshQuery().FindNearestPoly(point, new RcVec3f(2, 4, 2), Navigation.Crowd.GetFilter(0), out nearestRef, out position, out _); - if (status.Failed()) { - logger.Warning("Failed to find nearest poly from position {Source} in field {MapId}", point, MapId); - return false; + // Try progressively larger search extents; helps when an entity gets pushed off the navmesh (e.g., knockback). + // Keep the first extent small for performance, then widen only on failure. + ReadOnlySpan extentsList = stackalloc RcVec3f[] { + new RcVec3f(2, 4, 2), + new RcVec3f(8, 16, 8), + new RcVec3f(32, 64, 32), + new RcVec3f(128, 256, 128), + }; + + DtNavMeshQuery query = Navigation.Crowd.GetNavMeshQuery(); + IDtQueryFilter filter = Navigation.Crowd.GetFilter(0); + + for (int i = 0; i < extentsList.Length; i++) { + RcVec3f ext = extentsList[i]; + DtStatus status = query.FindNearestPoly(point, ext, filter, out nearestRef, out position, out _); + if (status.Succeeded() && nearestRef != 0) { + return true; + } } - return true; + logger.Warning("Failed to find nearest poly from position {Source} in field {MapId}", point, MapId); + nearestRef = 0; + position = default; + return false; } public bool TryGetPlayerById(long characterId, [NotNullWhen(true)] out FieldPlayer? player) { diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/HomeFieldManager.cs b/Maple2.Server.Game/Manager/Field/FieldManager/HomeFieldManager.cs index 721769eb5..ddf9a6f4c 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/HomeFieldManager.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/HomeFieldManager.cs @@ -35,7 +35,6 @@ public override void Init() { .ForEach(cubePortal => SpawnCubePortal(cubePortal)); plot.Cubes.Values - .Where(plotCube => plotCube.Interact != null) .ToList() .ForEach(plotCube => AddFieldFunctionInteract(plotCube)); } diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs b/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs index 3b3c93eb3..2bdcbc5ba 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/IField.cs @@ -95,6 +95,8 @@ public virtual void Init() { } public bool LiftupCube(in Vector3B coordinates, [NotNullWhen(true)] out LiftupWeapon? liftupWeapon); public void MovePlayerAlongPath(string pathName); + public bool IsSpawnPointDead(int spawnId); + public bool RemoveNpc(int objectId, TimeSpan removeDelay = default); public bool RemovePet(int objectId, TimeSpan removeDelay = default); } diff --git a/Maple2.Server.Game/Manager/Field/TriggerCollection.cs b/Maple2.Server.Game/Manager/Field/TriggerCollection.cs index b76c198c3..1efa4ec9e 100644 --- a/Maple2.Server.Game/Manager/Field/TriggerCollection.cs +++ b/Maple2.Server.Game/Manager/Field/TriggerCollection.cs @@ -1,36 +1,55 @@ using System.Collections; +using System.Collections.Generic; using Maple2.Model.Game; using Maple2.Model.Metadata; namespace Maple2.Server.Game.Manager.Field; public sealed class TriggerCollection : IReadOnlyCollection { - public readonly IReadOnlyDictionary Actors; - public readonly IReadOnlyDictionary Cameras; - public readonly IReadOnlyDictionary Cubes; - public readonly IReadOnlyDictionary Effects; - public readonly IReadOnlyDictionary Ladders; - public readonly IReadOnlyDictionary Meshes; - public readonly IReadOnlyDictionary Ropes; - public readonly IReadOnlyDictionary Sounds; - public readonly IReadOnlyDictionary Agents; - - public readonly IReadOnlyDictionary Boxes; + // NOTE: These collections need to be mutable at runtime. + // Some map packs / DB imports are missing Ms2TriggerMesh / Ms2TriggerAgent entries, + // but trigger scripts still reference those IDs (set_mesh / set_agent). + // The client already knows the objects by triggerId (from the map file), and only + // needs server updates to toggle visibility/collision. + // + // We therefore support creating lightweight placeholder trigger objects on demand. + private readonly Dictionary actors; + private readonly Dictionary cameras; + private readonly Dictionary cubes; + private readonly Dictionary effects; + private readonly Dictionary ladders; + private readonly Dictionary meshes; + private readonly Dictionary ropes; + private readonly Dictionary sounds; + private readonly Dictionary agents; + private readonly Dictionary boxes; + + public IReadOnlyDictionary Actors => actors; + public IReadOnlyDictionary Cameras => cameras; + public IReadOnlyDictionary Cubes => cubes; + public IReadOnlyDictionary Effects => effects; + public IReadOnlyDictionary Ladders => ladders; + public IReadOnlyDictionary Meshes => meshes; + public IReadOnlyDictionary Ropes => ropes; + public IReadOnlyDictionary Sounds => sounds; + public IReadOnlyDictionary Agents => agents; + + public IReadOnlyDictionary Boxes => boxes; // These seem to get managed separately... // private readonly IReadOnlyDictionary Agents; // private readonly IReadOnlyDictionary Skills; public TriggerCollection(MapEntityMetadata entities) { - Dictionary actors = new(); - Dictionary cameras = new(); - Dictionary cubes = new(); - Dictionary effects = new(); - Dictionary ladders = new(); - Dictionary meshes = new(); - Dictionary ropes = new(); - Dictionary sounds = new(); - Dictionary agents = new(); + actors = new(); + cameras = new(); + cubes = new(); + effects = new(); + ladders = new(); + meshes = new(); + ropes = new(); + sounds = new(); + agents = new(); foreach (Ms2TriggerActor actor in entities.Trigger.Actors) { actors[actor.TriggerId] = new TriggerObjectActor(actor); @@ -61,36 +80,55 @@ public TriggerCollection(MapEntityMetadata entities) { agents[agent.TriggerId] = new TriggerObjectAgent(agent); } - Actors = actors; - Cameras = cameras; - Cubes = cubes; - Effects = effects; - Ladders = ladders; - Meshes = meshes; - Ropes = ropes; - Sounds = sounds; - Agents = agents; - - Dictionary boxes = new(); + boxes = new(); foreach (Ms2TriggerBox box in entities.Trigger.Boxes) { boxes[box.TriggerId] = new TriggerBox(box); } + } + + /// + /// Creates or retrieves a Trigger Mesh placeholder. Useful when a trigger script references + /// a mesh id that is missing from the DB import. + /// + public TriggerObjectMesh GetOrAddMesh(int triggerId) { + if (meshes.TryGetValue(triggerId, out TriggerObjectMesh? mesh)) { + return mesh; + } + + // Scale/minimapInvisible are not important for updates; the client already has the actual mesh. + Ms2TriggerMesh meta = new Ms2TriggerMesh(1f, triggerId, Visible: true, MinimapInvisible: false); + mesh = new TriggerObjectMesh(meta); + meshes[triggerId] = mesh; + return mesh; + } + + /// + /// Creates or retrieves a Trigger Agent placeholder. Useful when a trigger script references + /// an agent id that is missing from the DB import. + /// + public TriggerObjectAgent GetOrAddAgent(int triggerId) { + if (agents.TryGetValue(triggerId, out TriggerObjectAgent? agent)) { + return agent; + } - Boxes = boxes; + Ms2TriggerAgent meta = new Ms2TriggerAgent(triggerId, Visible: true); + agent = new TriggerObjectAgent(meta); + agents[triggerId] = agent; + return agent; } - public int Count => Actors.Count + Cameras.Count + Cubes.Count + Effects.Count + Ladders.Count + Meshes.Count + Ropes.Count + Sounds.Count + Agents.Count; + public int Count => actors.Count + cameras.Count + cubes.Count + effects.Count + ladders.Count + meshes.Count + ropes.Count + sounds.Count + agents.Count; public IEnumerator GetEnumerator() { - foreach (TriggerObjectActor actor in Actors.Values) yield return actor; - foreach (TriggerObjectCamera camera in Cameras.Values) yield return camera; - foreach (TriggerObjectCube cube in Cubes.Values) yield return cube; - foreach (TriggerObjectEffect effect in Effects.Values) yield return effect; - foreach (TriggerObjectLadder ladder in Ladders.Values) yield return ladder; - foreach (TriggerObjectMesh mesh in Meshes.Values) yield return mesh; - foreach (TriggerObjectRope rope in Ropes.Values) yield return rope; - foreach (TriggerObjectSound sound in Sounds.Values) yield return sound; - foreach (TriggerObjectAgent agent in Agents.Values) yield return agent; + foreach (TriggerObjectActor actor in actors.Values) yield return actor; + foreach (TriggerObjectCamera camera in cameras.Values) yield return camera; + foreach (TriggerObjectCube cube in cubes.Values) yield return cube; + foreach (TriggerObjectEffect effect in effects.Values) yield return effect; + foreach (TriggerObjectLadder ladder in ladders.Values) yield return ladder; + foreach (TriggerObjectMesh mesh in meshes.Values) yield return mesh; + foreach (TriggerObjectRope rope in ropes.Values) yield return rope; + foreach (TriggerObjectSound sound in sounds.Values) yield return sound; + foreach (TriggerObjectAgent agent in agents.Values) yield return agent; } IEnumerator IEnumerable.GetEnumerator() { diff --git a/Maple2.Server.Game/Manager/FishingManager.cs b/Maple2.Server.Game/Manager/FishingManager.cs index aebd61425..60c5b94a4 100644 --- a/Maple2.Server.Game/Manager/FishingManager.cs +++ b/Maple2.Server.Game/Manager/FishingManager.cs @@ -336,6 +336,21 @@ private void AddFish(int fishSize, bool hasAutoFish) { session.Field.Broadcast(FishingPacket.PrizeFish(session.PlayerName, selectedFish.Id)); } session.ConditionUpdate(ConditionType.fish, codeLong: selectedFish.Id, targetLong: session.Field.MapId); + // ====== 钓鱼给角色经验 ====== + long charExp = selectedFish.Exp; + + // 可选倍率:首次 / 奖牌鱼 + if (caughtFishType == CaughtFishType.FirstKind) { + charExp *= 2; + } + if (caughtFishType == CaughtFishType.Prize) { + charExp *= 2; + } + + if (charExp > 0) { + session.Exp.AddExp(ExpType.fishing, modifier: 0f, additionalExp: charExp); + } + // ====== 角色经验结束 ====== if (masteryExp == 0) { return; diff --git a/Maple2.Server.Game/Manager/HousingManager.cs b/Maple2.Server.Game/Manager/HousingManager.cs index eafda1f3d..340e6defa 100644 --- a/Maple2.Server.Game/Manager/HousingManager.cs +++ b/Maple2.Server.Game/Manager/HousingManager.cs @@ -16,6 +16,7 @@ using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; using Serilog; namespace Maple2.Server.Game.Manager; @@ -373,7 +374,7 @@ public void InitNewHome(string characterName, ExportedUgcMapMetadata? template) continue; } - session.FunctionCubeMetadata.TryGet(itemMetadata.Install.ObjectCubeId, out FunctionCubeMetadata? functionCubeMetadata); + FunctionCubeMetadata? functionCubeMetadata = HousingFunctionFurnitureRegistry.Resolve(itemMetadata, session.FunctionCubeMetadata); PlotCube? plotCube = CreateCube(Home.Indoor, itemMetadata, functionCubeMetadata, null, template.BaseCubePosition + cube.OffsetPosition, cube.Rotation); if (plotCube is null) { logger.Error("Failed to create cube {cubeId} at position {position}.", cube.ItemId, template.BaseCubePosition + cube.OffsetPosition); @@ -582,12 +583,28 @@ public bool TryPlaceCube(HeldCube cube, Plot plot, ItemMetadata itemMetadata, in return false; } + FunctionCubeMetadata? functionCubeMetadata = HousingFunctionFurnitureRegistry.Resolve(itemMetadata, session.FunctionCubeMetadata); + bool isSolidCube = itemMetadata.Install!.IsSolidCube; bool isOnGround = Math.Abs(position.Z - groundHeight) < 0.1; bool allowWaterOnGround = itemMetadata.Install.MapAttribute is MapAttribute.water && Constant.AllowWaterOnGround; + bool allowInteractiveFurnitureOnGround = functionCubeMetadata is { + ControlType: InteractCubeControlType.Switch or + InteractCubeControlType.Skill or + InteractCubeControlType.Ride or + InteractCubeControlType.SpawnNPC or + InteractCubeControlType.Sensor or + InteractCubeControlType.FunctionUI or + InteractCubeControlType.Notice or + InteractCubeControlType.InstallNPC or + InteractCubeControlType.Portal or + InteractCubeControlType.SpawnPoint or + InteractCubeControlType.PVP + }; - // If the cube is not a solid cube and it's replacing ground, it's not allowed. - if ((!isSolidCube && isOnGround) && !allowWaterOnGround) { + // Some interactive furnishings (fans, traps, switches, NPC furnishings) are authored as non-solid cubes. + // Allow them to be placed on the ground instead of rejecting them as floor replacements. + if ((!isSolidCube && isOnGround) && !allowWaterOnGround && !allowInteractiveFurnitureOnGround) { session.Send(CubePacket.Error(UgcMapError.s_ugcmap_cant_create_on_place)); return false; } @@ -613,8 +630,6 @@ public bool TryPlaceCube(HeldCube cube, Plot plot, ItemMetadata itemMetadata, in return false; } - session.FunctionCubeMetadata.TryGet(itemMetadata.Install.ObjectCubeId, out FunctionCubeMetadata? functionCubeMetadata); - if (plot.IsPlanner) { if (shopEntry is null && cube.Id == 0) { session.Send(CubePacket.Error(UgcMapError.s_ugcmap_cant_additionalbuy)); @@ -704,6 +719,9 @@ public bool TryRemoveCube(Plot plot, in Vector3B position, [NotNullWhen(true)] o } if (cube.Interact is not null) { + if (session.Field is not null) { + HousingFunctionFurnitureRegistry.Cleanup(session.Field, cube); + } session.Field?.RemoveFieldFunctionInteract(cube.Interact.Id); } diff --git a/Maple2.Server.Game/Manager/ItemEnchantManager.cs b/Maple2.Server.Game/Manager/ItemEnchantManager.cs index a074a6c04..4ede42b35 100644 --- a/Maple2.Server.Game/Manager/ItemEnchantManager.cs +++ b/Maple2.Server.Game/Manager/ItemEnchantManager.cs @@ -459,9 +459,9 @@ public bool StageLimitBreakItem(long itemUid) { } foreach ((SpecialAttribute attribute, int value) in optionMetadata.SpecialValues) { if (limitBreakItemUpgrade.LimitBreak.SpecialOptions.TryGetValue(attribute, out SpecialOption existing)) { - limitBreakItemUpgrade.LimitBreak.SpecialOptions[attribute] = existing + new SpecialOption(value); + limitBreakItemUpgrade.LimitBreak.SpecialOptions[attribute] = existing + new SpecialOption(0, value); } else { - limitBreakItemUpgrade.LimitBreak.SpecialOptions[attribute] = new SpecialOption(value); + limitBreakItemUpgrade.LimitBreak.SpecialOptions[attribute] = new SpecialOption(0, value); } } foreach ((SpecialAttribute attribute, float rate) in optionMetadata.SpecialRates) { diff --git a/Maple2.Server.Game/Manager/Items/EquipManager.cs b/Maple2.Server.Game/Manager/Items/EquipManager.cs index c594de5a3..ae4b3503b 100644 --- a/Maple2.Server.Game/Manager/Items/EquipManager.cs +++ b/Maple2.Server.Game/Manager/Items/EquipManager.cs @@ -146,6 +146,8 @@ public bool Equip(long itemUid, EquipSlot slot, bool isSkin) { if (item.Template != null) { session.ConditionUpdate(ConditionType.change_ugc_equip); } + + session.Config.Skill.UpdatePassiveBuffs(refreshStats: false); session.Stats.Refresh(); return true; } @@ -162,6 +164,7 @@ public bool Unequip(long itemUid) { foreach ((EquipSlot slot, Item item) in Gear) { if (itemUid == item.Uid) { if (UnequipInternal(slot, false)) { + session.Config.Skill.UpdatePassiveBuffs(refreshStats: false); session.Stats.Refresh(); return true; } @@ -172,6 +175,7 @@ public bool Unequip(long itemUid) { foreach ((EquipSlot slot, Item item) in Outfit) { if (itemUid == item.Uid) { if (UnequipInternal(slot, true)) { + session.Config.Skill.UpdatePassiveBuffs(refreshStats: false); session.Stats.Refresh(); return true; } diff --git a/Maple2.Server.Game/Manager/PetManager.cs b/Maple2.Server.Game/Manager/PetManager.cs index 77cdccacb..711bb8305 100644 --- a/Maple2.Server.Game/Manager/PetManager.cs +++ b/Maple2.Server.Game/Manager/PetManager.cs @@ -169,6 +169,7 @@ public void Dispose() { session.Field?.RemovePet(pet.ObjectId); session.Field?.Broadcast(PetPacket.UnSummon(pet)); session.Pet = null; + session.Stats.Refresh(); } #region Internal (No Locks) diff --git a/Maple2.Server.Game/Manager/SkillManager.cs b/Maple2.Server.Game/Manager/SkillManager.cs index 45f5c5754..41c36d5e3 100644 --- a/Maple2.Server.Game/Manager/SkillManager.cs +++ b/Maple2.Server.Game/Manager/SkillManager.cs @@ -31,13 +31,19 @@ public void LoadSkillBook() { session.Send(SkillBookPacket.Load(SkillBook)); } - public void UpdatePassiveBuffs(bool notifyField = true) { - // TODO: Only remove buffs that have been unlearned. - /*foreach (Buff buff in session.Player.Buffs.Buffs.Values) { - if (buff.StartTick == buff.EndTick) { - buff.Remove(); - } - }*/ + public short ResolveSkillLevel(int skillId, short requestedLevel = 1) { + SkillInfo.Skill? learnedSkill = SkillInfo.GetSkill(skillId); + if (learnedSkill is { Level: > 0 }) { + return learnedSkill.Level; + } + + return requestedLevel > 0 ? requestedLevel : (short) 1; + } + + public void UpdatePassiveBuffs(bool notifyField = true, bool refreshStats = true) { + RemovePassiveBuffs(); + + long fieldTick = session.Player.Field?.FieldTick ?? session.Field?.FieldTick ?? 0; // Add job passive skills to Player. foreach (SkillInfo.Skill skill in session.Config.Skill.SkillInfo.GetSkills(SkillType.Passive, SkillRank.Both)) { @@ -52,13 +58,57 @@ public void UpdatePassiveBuffs(bool notifyField = true) { logger.Information("Applying passive skill {Name}: {SkillId},{Level}", metadata.Name, metadata.Id, metadata.Level); foreach (SkillEffectMetadata effect in metadata.Data.Skills) { - if (effect.Condition is not { Target: SkillTargetType.Owner }) { + if (!CanApplyPassiveEffect(effect)) { continue; } - session.Player.ApplyEffect(session.Player, session.Player, effect, session.Player.Field.FieldTick, EventConditionType.Activate, skillId: metadata.Id, notifyField: notifyField); + session.Player.ApplyEffect(session.Player, session.Player, effect, fieldTick, EventConditionType.Activate, skillId: metadata.Id, notifyField: notifyField); + } + + foreach (SkillMetadataChange.Effect changeEffect in metadata.Data.Change?.Effects ?? Array.Empty()) { + session.Player.Buffs.AddBuff(session.Player, session.Player, changeEffect.Id, (short) changeEffect.Level, fieldTick, changeEffect.OverlapCount, notifyField: notifyField); } } + + if (refreshStats) { + session.Stats.Refresh(); + } + } + + private void RemovePassiveBuffs() { + var passiveBuffIds = new HashSet(); + + foreach (SkillInfo.Skill skill in SkillInfo.GetSkills(SkillType.Passive, SkillRank.Both)) { + if (skill.Level <= 0) { + continue; + } + + if (!session.SkillMetadata.TryGet(skill.Id, skill.Level, out SkillMetadata? metadata)) { + continue; + } + + foreach (SkillEffectMetadata effect in metadata.Data.Skills) { + foreach (SkillEffectMetadata.Skill effectSkill in effect.Skills) { + passiveBuffIds.Add(effectSkill.Id); + } + } + + foreach (SkillMetadataChange.Effect changeEffect in metadata.Data.Change?.Effects ?? Array.Empty()) { + passiveBuffIds.Add(changeEffect.Id); + } + } + + foreach (int buffId in passiveBuffIds) { + session.Player.Buffs.Remove(buffId, session.Player.ObjectId); + } + } + + private static bool CanApplyPassiveEffect(SkillEffectMetadata effect) { + if (effect.Condition == null) { + return false; + } + + return effect.Condition.Target is SkillTargetType.Owner or SkillTargetType.Caster or SkillTargetType.PetOwner or SkillTargetType.None; } #region SkillBook @@ -109,6 +159,11 @@ public bool SaveSkillTab(long activeSkillTabId, SkillRank ranks, SkillTab? tab = } } + SkillTab? refreshedActiveTab = GetActiveTab(); + if (refreshedActiveTab != null) { + SkillInfo.SetTab(refreshedActiveTab); + } + session.Send(SkillBookPacket.Save(SkillBook, tab.Id, ranks)); return true; } @@ -144,6 +199,10 @@ private bool CreateSkillTab(SkillTab skillTab) { } SkillBook.SkillTabs.Add(createdSkillTab); + SkillTab? activeTab = GetActiveTab(); + if (activeTab != null) { + SkillInfo.SetTab(activeTab); + } return true; } #endregion diff --git a/Maple2.Server.Game/Manager/StatsManager.cs b/Maple2.Server.Game/Manager/StatsManager.cs index 61c3122d8..f625defee 100644 --- a/Maple2.Server.Game/Manager/StatsManager.cs +++ b/Maple2.Server.Game/Manager/StatsManager.cs @@ -73,8 +73,8 @@ public StatsManager(IActor actor) { return (1, 1); double BonusAttackCoefficient(FieldPlayer player) { - int leftHandRarity = player.Session.Item.Equips.Get(EquipSlot.RH)?.Rarity ?? 0; - int rightHandRarity = player.Session.Item.Equips.Get(EquipSlot.LH)?.Rarity ?? 0; + int rightHandRarity = player.Session.Item.Equips.Get(EquipSlot.RH)?.Rarity ?? 0; + int leftHandRarity = player.Session.Item.Equips.Get(EquipSlot.LH)?.Rarity ?? 0; return BonusAttack.Coefficient(rightHandRarity, leftHandRarity, player.Value.Character.Job.Code()); } } @@ -138,8 +138,14 @@ public void Refresh() { AddEquips(player); AddBuffs(player); + player.Session.Config.ReapplyAllocatedStats(); Values.Total(); StatConversion(player); + // Stat rebuild via AddBase/AddTotal restores Current to Total, + // but a dead player must stay at 0 HP until revived. + if (player.IsDead) { + Values[BasicAttribute.Health].Current = 0; + } Actor.Field.Broadcast(StatsPacket.Init(player)); Actor.Field.Broadcast(StatsPacket.Update(player), player.Session); @@ -176,6 +182,9 @@ private void AddEquips(FieldPlayer player) { foreach (KeyValuePair kvp in item.LimitBreak.BasicOptions) { Values[kvp.Key].AddTotal(kvp.Value); } + foreach (KeyValuePair kvp in item.LimitBreak.SpecialOptions) { + Values[kvp.Key].AddTotal(kvp.Value); + } } if (item.Socket != null) { @@ -205,25 +214,66 @@ private void AddEquips(FieldPlayer player) { Values.GearScore += totalGearScore; } + AddSummonedPetStats(player); + Log.Logger.Debug("Final Gearscore for {name} ({id}): {gearscore}", player.Value.Character.Name, player.Value.Character.Id, Values.GearScore); player.Session.Dungeon.UpdateDungeonEnterLimit(); player.Session.ConditionUpdate(ConditionType.item_gear_score, counter: Values.GearScore); } + private static readonly HashSet AdditiveBuffBasicRates = [ + BasicAttribute.Piercing, + ]; + + private static readonly HashSet AdditiveBuffSpecialStats = [ + SpecialAttribute.TotalDamage, + SpecialAttribute.NormalNpcDamage, + SpecialAttribute.LeaderNpcDamage, + SpecialAttribute.EliteNpcDamage, + SpecialAttribute.BossNpcDamage, + SpecialAttribute.FireDamage, + SpecialAttribute.IceDamage, + SpecialAttribute.ElectricDamage, + SpecialAttribute.HolyDamage, + SpecialAttribute.DarkDamage, + SpecialAttribute.PoisonDamage, + SpecialAttribute.MeleeDamage, + SpecialAttribute.RangedDamage, + SpecialAttribute.PhysicalPiercing, + SpecialAttribute.MagicalPiercing, + SpecialAttribute.OffensivePhysicalDamage, + SpecialAttribute.OffensiveMagicalDamage, + SpecialAttribute.PvpDamage, + SpecialAttribute.DarkStreamDamage, + SpecialAttribute.ChaosRaidAttack, + ]; + private void AddBuffs(FieldPlayer player) { foreach (Buff buff in player.Buffs.EnumerateBuffs()) { foreach ((BasicAttribute valueBasicAttribute, long value) in buff.Metadata.Status.Values) { Values[valueBasicAttribute].AddTotal(value); } foreach ((BasicAttribute rateBasicAttribute, float rate) in buff.Metadata.Status.Rates) { - // ensure regen intervals do not drop below 0.1 - Values[rateBasicAttribute].AddRate(rate); + if (AdditiveBuffBasicRates.Contains(rateBasicAttribute)) { + Values[rateBasicAttribute].AddScaledTotal(rate); + } else { + // ensure regen intervals do not drop below 0.1 + Values[rateBasicAttribute].AddRate(rate); + } } foreach ((SpecialAttribute valueSpecialAttribute, float value) in buff.Metadata.Status.SpecialValues) { - Values[valueSpecialAttribute].AddTotal((long) value); + if (AdditiveBuffSpecialStats.Contains(valueSpecialAttribute)) { + Values[valueSpecialAttribute].AddScaledTotal(value); + } else { + Values[valueSpecialAttribute].AddTotal((long) Math.Round(value)); + } } foreach ((SpecialAttribute rateSpecialAttribute, float rate) in buff.Metadata.Status.SpecialRates) { - Values[rateSpecialAttribute].AddRate(rate); + if (AdditiveBuffSpecialStats.Contains(rateSpecialAttribute)) { + Values[rateSpecialAttribute].AddScaledTotal(rate); + } else { + Values[rateSpecialAttribute].AddRate(rate); + } } } } @@ -238,6 +288,15 @@ private void StatConversion(FieldPlayer player) { } } + private void AddSummonedPetStats(FieldPlayer player) { + Item? summonedPet = player.Session.Pet?.Pet; + if (summonedPet?.Stats == null) { + return; + } + + AddItemStats(summonedPet.Stats); + } + private void AddItemStats(ItemStats stats) { for (int type = 0; type < ItemStats.TYPE_COUNT; type++) { foreach ((BasicAttribute attribute, BasicOption option) in stats[(ItemStats.Type) type].Basic) { diff --git a/Maple2.Server.Game/Manager/SurvivalManager.cs b/Maple2.Server.Game/Manager/SurvivalManager.cs index fd0e506cf..a88ca6bc8 100644 --- a/Maple2.Server.Game/Manager/SurvivalManager.cs +++ b/Maple2.Server.Game/Manager/SurvivalManager.cs @@ -1,8 +1,11 @@ -using System.Collections.Concurrent; +using System.Collections.Concurrent; using Maple2.Database.Extensions; using Maple2.Database.Storage; using Maple2.Model.Enum; using Maple2.Model.Game; +using Maple2.Model.Metadata; +using Maple2.Server.Game.Config; +using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; using Maple2.Server.Game.Util; @@ -11,38 +14,41 @@ namespace Maple2.Server.Game.Manager; public sealed class SurvivalManager { - private readonly GameSession session; + private static readonly Lazy ConfigLoader = new Lazy(SurvivalPassXmlConfig.Load); + private static SurvivalPassXmlConfig Config { + get { return ConfigLoader.Value; } + } + private readonly GameSession session; private readonly ILogger logger = Log.Logger.ForContext(); private readonly ConcurrentDictionary> inventory; private readonly ConcurrentDictionary equip; private int SurvivalLevel { - get => session.Player.Value.Account.SurvivalLevel; - set => session.Player.Value.Account.SurvivalLevel = value; + get { return session.Player.Value.Account.SurvivalLevel; } + set { session.Player.Value.Account.SurvivalLevel = value; } } private long SurvivalExp { - get => session.Player.Value.Account.SurvivalExp; - set => session.Player.Value.Account.SurvivalExp = value; + get { return session.Player.Value.Account.SurvivalExp; } + set { session.Player.Value.Account.SurvivalExp = value; } } private int SurvivalSilverLevelRewardClaimed { - get => session.Player.Value.Account.SurvivalSilverLevelRewardClaimed; - set => session.Player.Value.Account.SurvivalSilverLevelRewardClaimed = value; + get { return session.Player.Value.Account.SurvivalSilverLevelRewardClaimed; } + set { session.Player.Value.Account.SurvivalSilverLevelRewardClaimed = value; } } private int SurvivalGoldLevelRewardClaimed { - get => session.Player.Value.Account.SurvivalGoldLevelRewardClaimed; - set => session.Player.Value.Account.SurvivalGoldLevelRewardClaimed = value; + get { return session.Player.Value.Account.SurvivalGoldLevelRewardClaimed; } + set { session.Player.Value.Account.SurvivalGoldLevelRewardClaimed = value; } } private bool ActiveGoldPass { - get => session.Player.Value.Account.ActiveGoldPass; - set => session.Player.Value.Account.ActiveGoldPass = value; + get { return session.Player.Value.Account.ActiveGoldPass; } + set { session.Player.Value.Account.ActiveGoldPass = value; } } - public SurvivalManager(GameSession session) { this.session = session; inventory = new ConcurrentDictionary>(); @@ -51,30 +57,372 @@ public SurvivalManager(GameSession session) { equip[type] = new Medal(0, type); inventory[type] = new Dictionary(); } + using GameStorage.Request db = session.GameStorage.Context(); List medals = db.GetMedals(session.CharacterId); - foreach (Medal medal in medals) { - if (!inventory.TryGetValue(medal.Type, out Dictionary? dict)) { + Dictionary dict; + if (!inventory.TryGetValue(medal.Type, out dict!)) { dict = new Dictionary(); inventory[medal.Type] = dict; } - dict[medal.Id] = medal; if (medal.Slot != -1) { equip[medal.Type] = medal; } } + + NormalizeProgress(); } - public void AddMedal(Item item) { + public void Load() { + NormalizeProgress(); + session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account, GetDisplayExp(), 0)); + session.Send(SurvivalPacket.LoadMedals(inventory, equip)); + } + + public void Save(GameStorage.Request db) { + var medals = inventory.Values.SelectMany(dict => dict.Values).ToArray(); + db.SaveMedals(session.CharacterId, medals); + } + + public long GetDisplayExp() { + long requiredExp = GetRequiredExpForCurrentLevel(); + if (requiredExp <= 0) { + return 0; + } + + if (SurvivalExp < 0) { + return 0; + } + + return Math.Min(SurvivalExp, requiredExp - 1); + } + + public void AddPassExp(int amount) { + if (amount <= 0) { + return; + } + + int oldLevel = SurvivalLevel; + SurvivalExp += amount; + + while (true) { + long requiredExp = GetRequiredExpForCurrentLevel(); + if (requiredExp <= 0 || SurvivalExp < requiredExp) { + break; + } + + SurvivalExp -= requiredExp; + SurvivalLevel++; + } + + if (oldLevel != SurvivalLevel) { + logger.Information("Survival level up account={AccountId} old={OldLevel} new={NewLevel} expInLevel={Exp}", session.Player.Value.Account.Id, oldLevel, SurvivalLevel, SurvivalExp); + } + + session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account, GetDisplayExp(), amount)); + } + + public int GetPassExpForNpc(FieldNpc npc) { + if (npc.Value.IsBoss) { + return Config.BossKillExp; + } + + NpcMetadataBasic basic = npc.Value.Metadata.Basic; + bool hasEliteTag = basic.MainTags.Any(tag => string.Equals(tag, "elite", StringComparison.OrdinalIgnoreCase) || string.Equals(tag, "champion", StringComparison.OrdinalIgnoreCase)) + || basic.SubTags.Any(tag => string.Equals(tag, "elite", StringComparison.OrdinalIgnoreCase) || string.Equals(tag, "champion", StringComparison.OrdinalIgnoreCase)); + bool isElite = basic.RareDegree >= 2 && hasEliteTag; + + return isElite ? Config.EliteKillExp : Config.MonsterKillExp; + } + + public bool TryUseGoldPassActivationItem(Item item) { + if (item == null) { + return false; + } + if (Config.ActivationItemId <= 0 || item.Id != Config.ActivationItemId) { + return false; + } + + logger.Information("Gold Pass activation requested by item use account={AccountId} itemId={ItemId} uid={Uid} amount={Amount}", + session.Player.Value.Account.Id, item.Id, item.Uid, item.Amount); + return TryActivateGoldPass(item); + } + + public bool TryUsePassExpItem(Item item) { + if (item == null || item.Metadata.Function?.Type != ItemFunction.SurvivalLevelExp) { + return false; + } + Dictionary parameters = XmlParseUtil.GetParameters(item.Metadata.Function?.Parameters); - if (!parameters.TryGetValue("id", out string? idStr) || !int.TryParse(idStr, out int id)) { + string expStr; + int expAmount; + if (!parameters.TryGetValue("exp", out expStr!) || !int.TryParse(expStr, out expAmount) || expAmount <= 0) { + return false; + } + + if (!session.Item.Inventory.Consume(item.Uid, 1)) { + return true; + } + + AddPassExp(expAmount); + return true; + } + + public bool TryUseSkinItem(Item item) { + if (item == null || item.Metadata.Function?.Type != ItemFunction.SurvivalSkin) { + return false; + } + + AddMedal(item); + session.Item.Inventory.Consume(item.Uid, 1); + return true; + } + + public bool TryActivateGoldPass() { + if (ActiveGoldPass) { + logger.Information("Gold Pass already active account={AccountId}", session.Player.Value.Account.Id); + return true; + } + + if (Config.ActivationItemId <= 0) { + if (!Config.AllowDirectActivateWithoutItem) { + logger.Information("Gold Pass activation rejected: no activation item configured and direct activation disabled account={AccountId}", session.Player.Value.Account.Id); + return false; + } + + ActivateGoldPass(); + logger.Information("Gold Pass activated without item account={AccountId}", session.Player.Value.Account.Id); + return true; + } + + Item? item = session.Item.Inventory.Find(Config.ActivationItemId).FirstOrDefault(); + if (item == null) { + logger.Information("Gold Pass activation failed: item not found account={AccountId} itemId={ItemId}", session.Player.Value.Account.Id, Config.ActivationItemId); + return false; + } + + return TryActivateGoldPass(item); + } + + + private bool TryActivateGoldPass(Item item) { + if (ActiveGoldPass) { + logger.Information("Gold Pass already active account={AccountId}", session.Player.Value.Account.Id); + return true; + } + if (item == null || item.Id != Config.ActivationItemId) { + logger.Information("Gold Pass activation failed: invalid item account={AccountId} itemId={ItemId} expected={ExpectedItemId}", + session.Player.Value.Account.Id, item != null ? item.Id : 0, Config.ActivationItemId); + return false; + } + if (item.Amount < Config.ActivationItemCount) { + logger.Information("Gold Pass activation failed: insufficient item count account={AccountId} itemId={ItemId} have={Have} need={Need}", + session.Player.Value.Account.Id, item.Id, item.Amount, Config.ActivationItemCount); + return false; + } + if (!session.Item.Inventory.Consume(item.Uid, Config.ActivationItemCount)) { + logger.Information("Gold Pass activation failed: consume returned false account={AccountId} itemId={ItemId} uid={Uid}", + session.Player.Value.Account.Id, item.Id, item.Uid); + return false; + } + + ActivateGoldPass(); + logger.Information("Gold Pass activated account={AccountId} by itemId={ItemId} uid={Uid}", session.Player.Value.Account.Id, item.Id, item.Uid); + return true; + } + + private void ActivateGoldPass() { + ActiveGoldPass = true; + session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account, GetDisplayExp(), 0)); + } + + public bool TryClaimNextReward() { + NormalizeProgress(); + + int nextFree = SurvivalSilverLevelRewardClaimed + 1; + if (CanClaimReward(nextFree, false)) { + return TryClaimReward(nextFree, false); + } + + int nextPaid = SurvivalGoldLevelRewardClaimed + 1; + if (CanClaimReward(nextPaid, true)) { + return TryClaimReward(nextPaid, true); + } + + return false; + } + + public bool TryClaimReward(int level, bool paidTrack) { + NormalizeProgress(); + if (!CanClaimReward(level, paidTrack)) { + return false; + } + + Dictionary rewards = paidTrack ? Config.PaidRewards : Config.FreeRewards; + SurvivalRewardEntry entry; + if (!rewards.TryGetValue(level, out entry!)) { + if (paidTrack) { + SurvivalGoldLevelRewardClaimed = level; + } else { + SurvivalSilverLevelRewardClaimed = level; + } + session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account, GetDisplayExp(), 0)); + return true; + } + + foreach (SurvivalRewardGrant grant in entry.Grants) { + if (!TryGrantReward(grant)) { + return false; + } + } + + if (paidTrack) { + SurvivalGoldLevelRewardClaimed = level; + } else { + SurvivalSilverLevelRewardClaimed = level; + } + + session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account, GetDisplayExp(), 0)); + return true; + } + + private bool CanClaimReward(int level, bool paidTrack) { + if (level <= 0 || level > SurvivalLevel) { + return false; + } + if (paidTrack && !ActiveGoldPass) { + return false; + } + return paidTrack ? level == SurvivalGoldLevelRewardClaimed + 1 : level == SurvivalSilverLevelRewardClaimed + 1; + } + + private long GetLevelThreshold(int level) { + long threshold; + return Config.LevelThresholds.TryGetValue(level, out threshold) ? threshold : 0; + } + + private long GetRequiredExpForCurrentLevel() { + long currentThreshold = GetLevelThreshold(SurvivalLevel); + long nextThreshold = GetNextLevelThreshold(SurvivalLevel); + long requiredExp = nextThreshold - currentThreshold; + return Math.Max(0, requiredExp); + } + + private long GetNextLevelThreshold(int level) { + long threshold; + return Config.LevelThresholds.TryGetValue(level + 1, out threshold) ? threshold : GetLevelThreshold(level); + } + + private void NormalizeProgress() { + if (SurvivalLevel <= 0) { + SurvivalLevel = 1; + } + if (SurvivalExp < 0) { + SurvivalExp = 0; + } + + while (true) { + long requiredExp = GetRequiredExpForCurrentLevel(); + if (requiredExp <= 0 || SurvivalExp < requiredExp) { + break; + } + + SurvivalExp -= requiredExp; + SurvivalLevel++; + } + + if (SurvivalSilverLevelRewardClaimed > SurvivalLevel) { + SurvivalSilverLevelRewardClaimed = SurvivalLevel; + } + if (SurvivalGoldLevelRewardClaimed > SurvivalLevel) { + SurvivalGoldLevelRewardClaimed = SurvivalLevel; + } + } + + private bool TryGrantReward(SurvivalRewardGrant grant) { + string type = grant.Type.Trim(); + if (string.Equals(type, "additionalEffect", StringComparison.OrdinalIgnoreCase)) { + logger.Information("Skipping unsupported survival additionalEffect reward id={IdRaw}", grant.IdRaw); + return true; + } + + int[] ids = ParseIntArray(grant.IdRaw); + int[] values = ParseIntArray(grant.ValueRaw); + int[] counts = ParseIntArray(grant.CountRaw); + + if (string.Equals(type, "genderItem", StringComparison.OrdinalIgnoreCase)) { + int chosenIndex = session.Player.Value.Character.Gender == Gender.Female ? 1 : 0; + int itemId = GetValueAt(ids, chosenIndex); + int rarity = Math.Max(1, GetValueAt(values, chosenIndex, 1)); + int count = Math.Max(1, GetValueAt(counts, chosenIndex, 1)); + return TryGrantItem(itemId, rarity, count); + } + + if (string.Equals(type, "item", StringComparison.OrdinalIgnoreCase)) { + int itemId = GetValueAt(ids, 0); + int rarity = Math.Max(1, GetValueAt(values, 0, 1)); + int count = Math.Max(1, GetValueAt(counts, 0, 1)); + return TryGrantItem(itemId, rarity, count); + } + + logger.Information("Skipping unsupported survival reward type={Type}", type); + return true; + } + + private bool TryGrantItem(int itemId, int rarity, int amount) { + if (itemId <= 0) { + return true; + } + if (session.Field == null) { + return false; + } + ItemMetadata metadata; + if (!session.ItemMetadata.TryGet(itemId, out metadata!)) { + logger.Warning("Missing item metadata for survival reward itemId={ItemId}", itemId); + return false; + } + + Item? item = session.Field.ItemDrop.CreateItem(itemId, rarity, amount); + if (item == null) { + logger.Warning("Failed to create survival reward item itemId={ItemId}", itemId); + return false; + } + return session.Item.Inventory.Add(item, true); + } + + private static int[] ParseIntArray(string csv) { + if (string.IsNullOrWhiteSpace(csv)) { + return Array.Empty(); + } + return csv.Split(',').Select(part => { + int parsed; + return int.TryParse(part, out parsed) ? parsed : 0; + }).ToArray(); + } + + private static int GetValueAt(int[] values, int index, int fallback = 0) { + if (values.Length == 0) { + return fallback; + } + if (index < values.Length) { + return values[index]; + } + return values[values.Length - 1]; + } + + public void AddMedal(Item item) { + Dictionary parameters = XmlParseUtil.GetParameters(item.Metadata.Function != null ? item.Metadata.Function.Parameters : null); + string idStr; + int id; + if (!parameters.TryGetValue("id", out idStr!) || !int.TryParse(idStr, out id)) { logger.Warning("Failed to add medal: missing or invalid ID parameter"); return; } - if (!parameters.TryGetValue("type", out string? typeStr)) { + string typeStr; + if (!parameters.TryGetValue("type", out typeStr!)) { logger.Warning("Failed to add medal: missing or invalid type parameter"); return; } @@ -83,20 +431,22 @@ public void AddMedal(Item item) { "effectTail" => MedalType.Tail, "gliding" => MedalType.Gliding, "riding" => MedalType.Riding, - _ => throw new InvalidOperationException($"Invalid medal type: {typeStr}"), + _ => throw new InvalidOperationException("Invalid medal type: " + typeStr), }; long expiryTime = DateTime.MaxValue.ToEpochSeconds() - 1; - // Get expiration - if (parameters.TryGetValue("durationSec", out string? durationStr) && int.TryParse(durationStr, out int durationSec)) { - expiryTime = (long) (DateTime.Now.ToUniversalTime() - DateTime.UnixEpoch).TotalSeconds + durationSec; - } else if (parameters.TryGetValue("endDate", out string? endDateStr) && DateTime.TryParseExact(endDateStr, "yyyy-MM-dd-HH-mm-ss", null, System.Globalization.DateTimeStyles.None, out DateTime endDate)) { - //2018-10-02-00-00-00 - expiryTime = endDate.ToEpochSeconds(); + string durationStr; + if (parameters.TryGetValue("durationSec", out durationStr!) && int.TryParse(durationStr, out int durationSec)) { + expiryTime = (long)(DateTime.Now.ToUniversalTime() - DateTime.UnixEpoch).TotalSeconds + durationSec; + } else { + string endDateStr; + if (parameters.TryGetValue("endDate", out endDateStr!) && DateTime.TryParseExact(endDateStr, "yyyy-MM-dd-HH-mm-ss", null, System.Globalization.DateTimeStyles.None, out DateTime endDate)) { + expiryTime = endDate.ToEpochSeconds(); + } } - // Check if medal already exists - if (inventory[type].TryGetValue(id, out Medal? existing)) { + Medal existing; + if (inventory[type].TryGetValue(id, out existing!)) { existing.ExpiryTime = Math.Min(existing.ExpiryTime + expiryTime, DateTime.MaxValue.ToEpochSeconds() - 1); session.Send(SurvivalPacket.LoadMedals(inventory, equip)); return; @@ -107,13 +457,13 @@ public void AddMedal(Item item) { return; } - if (!inventory.TryGetValue(medal.Type, out Dictionary? dict)) { + Dictionary dict; + if (!inventory.TryGetValue(medal.Type, out dict!)) { dict = new Dictionary(); inventory[medal.Type] = dict; } dict[medal.Id] = medal; - session.Send(SurvivalPacket.LoadMedals(inventory, equip)); } @@ -128,24 +478,22 @@ public bool Equip(MedalType type, int id) { return true; } - if (!inventory[type].TryGetValue(id, out Medal? medal)) { + Medal medal; + if (!inventory[type].TryGetValue(id, out medal!)) { return false; } - // medal is already equipped if (medal.Slot != -1) { return false; } - // unequip existing medal if (equip[type].Id != 0) { Medal equipped = equip[type]; equipped.Slot = -1; } equip[type] = medal; - medal.Slot = (short) type; - + medal.Slot = (short)type; session.Send(SurvivalPacket.LoadMedals(inventory, equip)); return true; } @@ -157,21 +505,8 @@ private void Unequip(MedalType type) { } private Medal? CreateMedal(int id, MedalType type, long expiryTime) { - var medal = new Medal(id, type) { - ExpiryTime = expiryTime, - }; - + var medal = new Medal(id, type) { ExpiryTime = expiryTime }; using GameStorage.Request db = session.GameStorage.Context(); return db.CreateMedal(session.CharacterId, medal); } - - public void Load() { - session.Send(SurvivalPacket.UpdateStats(session.Player.Value.Account)); - session.Send(SurvivalPacket.LoadMedals(inventory, equip)); - } - - public void Save(GameStorage.Request db) { - var medals = inventory.Values.SelectMany(dict => dict.Values).ToArray(); - db.SaveMedals(session.CharacterId, medals); - } } diff --git a/Maple2.Server.Game/Maple2.Server.Game.csproj b/Maple2.Server.Game/Maple2.Server.Game.csproj index 9ae8babff..1efc53224 100644 --- a/Maple2.Server.Game/Maple2.Server.Game.csproj +++ b/Maple2.Server.Game/Maple2.Server.Game.csproj @@ -52,5 +52,8 @@ Always + + Always + \ No newline at end of file diff --git a/Maple2.Server.Game/Model/Field/Actor/Actor.cs b/Maple2.Server.Game/Model/Field/Actor/Actor.cs index 5ebe13220..e70b2474d 100644 --- a/Maple2.Server.Game/Model/Field/Actor/Actor.cs +++ b/Maple2.Server.Game/Model/Field/Actor/Actor.cs @@ -3,6 +3,7 @@ using System.Numerics; using Maple2.Model.Enum; using Maple2.Model.Metadata; +using Maple2.Model.Game.Dungeon; using Maple2.Server.Game.Manager.Field; using Maple2.Server.Game.Model.Skill; using Maple2.Tools.VectorMath; @@ -152,6 +153,38 @@ public virtual void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadat Stats.Values[BasicAttribute.Health].Add(damageAmount); Field.Broadcast(StatsPacket.Update(this, BasicAttribute.Health)); OnDamageReceived(caster, positiveDamage); + + if (caster is FieldPlayer casterPlayer) { + long totalDamage = 0; + long criticalDamage = 0; + long totalHitCount = 0; + + foreach ((DamageType damageType, long amount) in targetRecord.Damage) { + switch (damageType) { + case DamageType.Normal: + totalDamage += amount; + totalHitCount++; + break; + case DamageType.Critical: + totalDamage += amount; + criticalDamage += amount; + totalHitCount++; + break; + } + } + + AddDungeonAccumulation(casterPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.TotalDamage, totalDamage); + AddDungeonAccumulation(casterPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.TotalHitCount, totalHitCount); + AddDungeonAccumulation(casterPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.TotalCriticalDamage, criticalDamage); + SetDungeonAccumulationMax(casterPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.MaximumCriticalDamage, criticalDamage); + if (damage.SkillId == 0) { + AddDungeonAccumulation(casterPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.BasicAttackDamage, totalDamage); + } + } + + if (this is FieldPlayer targetPlayer) { + AddDungeonAccumulation(targetPlayer.Session.Dungeon.UserRecord, DungeonAccumulationRecordType.IncomingDamage, positiveDamage); + } } foreach ((DamageType damageType, long amount) in targetRecord.Damage) { @@ -175,6 +208,24 @@ public virtual void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadat } } + + private static void AddDungeonAccumulation(DungeonUserRecord? userRecord, DungeonAccumulationRecordType type, long amount) { + if (userRecord == null || amount <= 0) { + return; + } + + userRecord.AccumulationRecords.AddOrUpdate(type, (int) Math.Min(amount, int.MaxValue), + (_, current) => (int) Math.Min((long) current + amount, int.MaxValue)); + } + + private static void SetDungeonAccumulationMax(DungeonUserRecord? userRecord, DungeonAccumulationRecordType type, long amount) { + if (userRecord == null || amount <= 0) { + return; + } + + userRecord.AccumulationRecords.AddOrUpdate(type, (int) Math.Min(amount, int.MaxValue), + (_, current) => Math.Max(current, (int) Math.Min(amount, int.MaxValue))); + } protected virtual void OnDamageReceived(IActor caster, long amount) { } public virtual void Reflect(IActor target) { @@ -253,6 +304,7 @@ public virtual IActor GetTarget(SkillTargetType targetType, IActor caster, IActo SkillTargetType.Owner => owner, SkillTargetType.Target => target, SkillTargetType.Caster => caster, + SkillTargetType.PetOwner => owner, SkillTargetType.None => this, // Should be on self/inherit SkillTargetType.Attacker => target, _ => throw new NotImplementedException(), @@ -264,6 +316,7 @@ public IActor GetOwner(SkillTargetType targetType, IActor caster, IActor target, SkillTargetType.Owner => owner, SkillTargetType.Target => owner, SkillTargetType.Caster => caster, + SkillTargetType.PetOwner => owner, SkillTargetType.None => this, // Should be on self/inherit SkillTargetType.Attacker => target, _ => throw new NotImplementedException(), @@ -355,6 +408,10 @@ public virtual void Update(long tickCount) { public virtual void KeyframeEvent(string keyName) { } public virtual SkillRecord? CastSkill(int id, short level, long uid, int castTick, in Vector3 position = default, in Vector3 direction = default, in Vector3 rotation = default, float rotateZ = 0f, byte motionPoint = 0) { + if (this is FieldPlayer player) { + level = player.Session.Config.Skill.ResolveSkillLevel(id, level); + } + if (!Field.SkillMetadata.TryGet(id, level, out SkillMetadata? metadata)) { Logger.Error("Invalid skill use: {SkillId},{Level}", id, level); return null; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/AiState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/AiState.cs index 4e7b46f30..b5f55cce5 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/AiState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/AiState.cs @@ -6,6 +6,7 @@ using Serilog; using static Maple2.Model.Metadata.AiMetadata; using static Maple2.Server.Game.Model.ActorStateComponent.TaskState; +using System; namespace Maple2.Server.Game.Model.ActorStateComponent; @@ -107,18 +108,30 @@ public void Update(long tickCount) { } bool isInBattle = actor.BattleState.InBattle; - if (!isInBattle) { + actor.AiExtraData["__battle_start_tick"] = 0; + + bool suppressBattleEnd = actor.AiExtraData.GetValueOrDefault("__suppress_battle_end", 0) != 0; + long currentHp = actor.Stats.Values[BasicAttribute.Health].Current; + bool isActuallyDead = currentHp <= 0; + if (currentTree == DecisionTreeType.Battle) { aiStack.Clear(); - currentTree = battleEnd is not null ? DecisionTreeType.BattleEnd : DecisionTreeType.None; + if (suppressBattleEnd) { + actor.AiExtraData["__suppress_battle_end"] = 0; + currentTree = DecisionTreeType.None; + } else { + currentTree = isActuallyDead && battleEnd is not null + ? DecisionTreeType.BattleEnd + : DecisionTreeType.None; + } } else if (currentTree == DecisionTreeType.BattleEnd && aiStack.Count == 0) { currentTree = DecisionTreeType.None; } return; - } else if (currentTree == DecisionTreeType.BattleEnd) { + } else if (currentTree == DecisionTreeType.BattleEnd) { aiStack.Clear(); currentTree = DecisionTreeType.None; @@ -235,6 +248,109 @@ private void Push(AiPreset aiPreset) { return skill; } + private NpcMetadata? ResolveSummonMetadata(SummonNode node) { + if (node.NpcId >= 20000000 && actor.Field.NpcMetadata.TryGet(node.NpcId, out NpcMetadata? direct)) { + return direct; + } + + string currentAiPath = actor.Value.Metadata.AiPath ?? string.Empty; + string normalizedCurrent = currentAiPath.Replace('\\', '/'); + int preferredDifficulty = actor.Value.Metadata.Basic.Difficulty; + int preferredId = actor.Value.Id; + + NpcMetadata? FindPreferred(string aiPath) => actor.Field.NpcMetadata.FindByAiPath(aiPath, preferredDifficulty, preferredId); + if (actor.Value.Id == 23200081 && node.NpcId == 3) { + if (actor.Field.NpcMetadata.TryGet(23200082, out NpcMetadata? kanduraChaosNext)) { + return kanduraChaosNext; + } +} + +if (actor.Value.Id == 23000081 && node.NpcId == 3) { + if (actor.Field.NpcMetadata.TryGet(23000082, out NpcMetadata? kanduraRaidNext)) { + return kanduraRaidNext; + } +} + // 超链接之树 / Kandura + if (normalizedCurrent.Contains("BossDungeon/KanduraBigBurster", StringComparison.OrdinalIgnoreCase)) { + bool chaos = normalizedCurrent.Contains("Chaos", StringComparison.OrdinalIgnoreCase); + + return node.NpcId switch { + 1 => FindPreferred("BossDungeon/KanduraBigBurster/AI_SoldierPhysicalBlueSummon.xml"), + 2 => FindPreferred("BossDungeon/KanduraBigBurster/AI_SoldierPhysicalRedSummon.xml"), + 3 => FindPreferred( + chaos + ? "BossDungeon/KanduraBigBurster/AI_KanduraBigBurster_Chaos.xml" + : "BossDungeon/KanduraBigBurster/AI_KanduraBigBurster.xml" + ), + 4 => FindPreferred("BossDungeon/KanduraBigBurster/AI_SoldierPhysicalHandArmorSummon.xml"), + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + + // 月光船长要塞 / 船长默克 + if (normalizedCurrent.Contains("BossDungeon/CaptainHookFish01", StringComparison.OrdinalIgnoreCase)) { + return node.NpcId switch { + 1 => FindPreferred("BossDungeon/CaptainHookFish01/AI_MermanSmallBlue.xml"), + 2 => FindPreferred("BossDungeon/CaptainHookFish01/AI_MermanFatBlue.xml"), + 3 => FindPreferred("BossDungeon/CaptainHookFish01/AI_PirateSkullCannonSummonLeft.xml") + ?? FindPreferred("BossDungeon/CaptainHookFish01/AI_PirateSkullCannonSummon.xml"), + 4 => FindPreferred("BossDungeon/CaptainHookFish01/AI_PirateSkullDaggerSummon.xml"), + 5 => FindPreferred("BossDungeon/CaptainHookFish01/AI_PirateSkullCannonSummonRight.xml") + ?? FindPreferred("BossDungeon/CaptainHookFish01/AI_PirateSkullVikingSickleSummon.xml"), + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + + // 路贝里斯克 / BarkhantBlue + if (normalizedCurrent.Contains("BossDungeon/BarkhantBlue", StringComparison.OrdinalIgnoreCase)) { + bool chaos = normalizedCurrent.Contains("/Chaos/", StringComparison.OrdinalIgnoreCase); + return node.NpcId switch { + 1 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_KnightHollowArmorPurple_ThrowWheel.xml" : "BossDungeon/BarkhantBlue/AI_KnightHollowArmorPurple_ThrowWheel_TypeA.xml"), + 2 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CerberosTallPurple.xml" : "BossDungeon/BarkhantBlue/AI_CerberosTallPurple_TypeA.xml"), + 3 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CrowDevilWhite_Close.xml" : "BossDungeon/BarkhantBlue/AI_CrowDevilWhite_TypeA.xml"), + 4 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CerberosTallPurple.xml" : "BossDungeon/BarkhantBlue/AI_CerberosTallPurple_TypeB.xml"), + 5 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_DragonDevilBigHeadBlue.xml" : "BossDungeon/BarkhantBlue/AI_DragonDevilBigHeadBlueSummon_TypeA.xml"), + 6 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_BarkhantRedSummon_Chaos.xml" : "BossDungeon/BarkhantBlue/AI_BarkhantRedSummon.xml"), + 7 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_BarkhantWhiteSummon_Chaos.xml" : "BossDungeon/BarkhantBlue/AI_BarkhantWhiteSummon.xml"), + 8 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CrowDevilWhite_Close.xml" : "BossDungeon/BarkhantBlue/AI_DragonDevilBigHeadBlueSummon_TypeB.xml"), + 9 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CerberosTallPurple.xml" : "BossDungeon/BarkhantBlue/AI_CerberosTallPurple_TypeC.xml"), + 10 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_DragonDevilBigHeadBlueSummon_TypeC.xml" : "BossDungeon/BarkhantBlue/AI_DragonDevilBigHeadBlueSummon_TypeC.xml"), + 11 => FindPreferred(chaos ? "BossDungeon/BarkhantBlue/Chaos/AI_CrowDevilWhite_Long.xml" : "BossDungeon/BarkhantBlue/AI_KnightHollowArmorPurple_ThrowWheel_TypeB.xml"), + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + if (normalizedCurrent.EndsWith("AI_BarkhantBlue_Quest.xml", StringComparison.OrdinalIgnoreCase)) { + return node.NpcId switch { + 6 => actor.Field.NpcMetadata.TryGet(29000204, out NpcMetadata? red6) ? red6 : null, + 7 => actor.Field.NpcMetadata.TryGet(29000205, out NpcMetadata? white7) ? white7 : null, + 8 => actor.Field.NpcMetadata.TryGet(29000204, out NpcMetadata? red8) ? red8 : null, + 9 => actor.Field.NpcMetadata.TryGet(29000205, out NpcMetadata? white9) ? white9 : null, + 10 => actor.Field.NpcMetadata.TryGet(21402237, out NpcMetadata? p3Mob) ? p3Mob : null, + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + // 不灭神殿 / Balrog + if (normalizedCurrent.Contains("BossDungeon/Balrog", StringComparison.OrdinalIgnoreCase) || + normalizedCurrent.Contains("BossDungeon/DungeonOS03", StringComparison.OrdinalIgnoreCase)) { + if (normalizedCurrent.EndsWith("AI_Balrog.xml", StringComparison.OrdinalIgnoreCase)) { + return node.NpcId switch { + 1 => FindPreferred("BossDungeon/Balrog/AI_DragonDevilBigHeadRedSummonTypeA.xml"), + 2 => FindPreferred("BossDungeon/Balrog/AI_Tristan_Chaos.xml"), + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + + if (normalizedCurrent.EndsWith("AI_Balrog_Chaos.xml", StringComparison.OrdinalIgnoreCase)) { + return node.NpcId switch { + 1 => FindPreferred("BossDungeon/Balrog/AI_Tristan_Chaos.xml"), + 2 => FindPreferred("BossDungeon/Balrog/AI_DragonDevilBigHeadRedSummonTypeB.xml"), + _ => actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId) + }; + } + } + + return actor.Field.NpcMetadata.FindByRelativeAiAlias(currentAiPath, node.NpcId); + } private void Push(Entry entry) { Push((dynamic) entry); @@ -357,19 +473,18 @@ private void ProcessNode(SkillNode node) { private void ProcessNode(TeleportNode node) { actor.Position = node.Pos; + actor.SendControl = true; - if (node.FacePos == new Vector3(0, 0, 0)) { - return; - } - - Vector3 offset = node.FacePos - actor.Position; - float squareDistance = offset.LengthSquared(); + if (node.FacePos != Vector3.Zero) { + Vector3 offset = node.FacePos - actor.Position; + float squareDistance = offset.LengthSquared(); - if (MathF.Abs(squareDistance) < 1e-5f) { - return; + if (squareDistance > 1e-5f) { + actor.Transform.LookTo((1 / MathF.Sqrt(squareDistance)) * offset); + } } - actor.Transform.LookTo((1 / MathF.Sqrt(squareDistance)) * offset); + actor.Field.Broadcast(ProxyObjectPacket.UpdateNpc(actor)); } private void ProcessNode(StandbyNode node) { @@ -429,7 +544,26 @@ private void ProcessNode(ConditionsNode node) { Push(passed); } - private void ProcessNode(JumpNode node) { } + private void ProcessNode(JumpNode node) { + NpcTask? task = null; + if (node.HeightMultiplier > 0) { + task = actor.MovementState.TryFlyTo(node.Pos, true, speed: node.Speed, lookAt: true); + } else { + task = actor.MovementState.TryMoveTo(node.Pos, true, speed: node.Speed, lookAt: true); + } + + if (task is not null) { + SetNodeTask(task, 0); + } else { + actor.Position = node.Pos; + actor.SendControl = true; + actor.Field.Broadcast(ProxyObjectPacket.UpdateNpc(actor)); + } + + if (node.IsKeepBattle) { + actor.BattleState.KeepBattle = true; + } + } private void ProcessNode(SelectNode node) { var weightedEntries = new WeightedSet<(Entry, int)>(); @@ -464,17 +598,215 @@ private void ProcessNode(MoveNode node) { SetNodeTask(task, node.Limit); } - private void ProcessNode(SummonNode node) { } + private void ProcessNode(SummonNode node) { + NpcMetadata? npcData = ResolveSummonMetadata(node); + if (npcData is null) { + return; + } + Logger.Warning("[AISummon] actorId:{ActorId}, actorAi:{ActorAi}, alias:{Alias} -> npcId:{NpcId}, npcAi:{NpcAi}", + actor.Value.Id, + actor.Value.Metadata.AiPath, + node.NpcId, + npcData.Id, + npcData.AiPath); + + int count = node.NpcCount > 0 + ? node.NpcCount + : (node.NpcCountMax > 0 ? Random.Shared.Next(1, node.NpcCountMax + 1) : 1); + + bool detachFromMaster = node.Master == NodeSummonMaster.None; + string path = actor.Value.Metadata.AiPath?.Replace('\\', '/') ?? ""; + bool replacementBoss = detachFromMaster && path.Contains("KanduraBigBurster", StringComparison.OrdinalIgnoreCase) && node.NpcId == 3; + + void SpawnOne() { + Vector3 position = node.SummonPos + node.SummonPosOffset; + if (position == Vector3.Zero) { + position = actor.Position; + } + + if (node.SummonRadius != Vector3.Zero) { + float rx = node.SummonRadius.X == 0 ? 0 : (float) (Random.Shared.NextDouble() * 2 - 1) * node.SummonRadius.X; + float ry = node.SummonRadius.Y == 0 ? 0 : (float) (Random.Shared.NextDouble() * 2 - 1) * node.SummonRadius.Y; + float rz = node.SummonRadius.Z == 0 ? 0 : (float) (Random.Shared.NextDouble() * 2 - 1) * node.SummonRadius.Z; + position += new Vector3(rx, ry, rz); + } + + FieldNpc? summoned = actor.Field.SpawnNpc( + npcData, + position, + node.SummonRot == Vector3.Zero ? actor.Rotation : node.SummonRot + ); + + if (summoned is null) { + return; + } + + if (!detachFromMaster) { + summoned.AiExtraData["__master_oid"] = actor.ObjectId; + } + + summoned.AiExtraData["__summon_group"] = node.Group; + + if (detachFromMaster) { + summoned.SpawnPointId = actor.SpawnPointId; + summoned.BattleState.TargetNode = actor.BattleState.TargetNode; + summoned.BattleState.KeepBattle = actor.BattleState.InBattle || node.IsKeepBattle; + + if (actor.BattleState.Target is not null) { + summoned.BattleState.ForceTarget(actor.BattleState.Target); + } + + if (actor.BattleState.GrabbedUser is not null) { + summoned.BattleState.GrabbedUser = actor.BattleState.GrabbedUser; + } + + int inheritedBattleStart = actor.AiExtraData.GetValueOrDefault("__battle_start_tick", 0); + if (inheritedBattleStart != 0) { + summoned.AiExtraData["__battle_start_tick"] = inheritedBattleStart; + } + } + + if (replacementBoss) { + summoned.AiExtraData["__replacement_spawn"] = 1; + actor.AiExtraData["__replacement_remove"] = 1; + + // 超链接之树变身期间,先把旧的清关键清掉,避免旧Boss流程污染 + actor.Field.UserValues["KanduraNormalDead"] = 0; + actor.Field.UserValues["ThirdPhaseEnd"] = 0; + + // 让新Boss明确知道自己已经是二阶段接力,不是独立开场 + summoned.AiExtraData["SecondPhaseStart"] = 1; + } + bool inheritMasterHp = + node.Option.Contains(NodeSummonOption.MasterHp) || node.Option.Contains(NodeSummonOption.LinkHp); + + if (inheritMasterHp) { + Stat masterHp = actor.Stats.Values[BasicAttribute.Health]; + Stat summonHp = summoned.Stats.Values[BasicAttribute.Health]; + + if (masterHp.Total > 0 && summonHp.Total > 0) { + double ratio = Math.Clamp((double) masterHp.Current / masterHp.Total, 0d, 1d); + summonHp.Current = Math.Clamp((long) Math.Round(summonHp.Total * ratio), 1, summonHp.Total); + } + } + + actor.Field.Broadcast(FieldPacket.AddNpc(summoned)); + actor.Field.Broadcast(ProxyObjectPacket.AddNpc(summoned)); + } + + for (int i = 0; i < count; i++) { + if (node.DelayTick > 0) { + actor.Field.Scheduler.Schedule(SpawnOne, TimeSpan.FromMilliseconds(node.DelayTick)); + } else { + SpawnOne(); + } + } + + if (node.IsKeepBattle) { + actor.BattleState.KeepBattle = true; + } + } private void ProcessNode(TriggerSetUserValueNode node) { + long hp = actor.Stats.Values[BasicAttribute.Health].Current; + bool isDead = actor.IsDead; + + // 超链接之树:23200082 只允许“真正死亡后”再写 ThirdPhaseEnd + if (node.Key == "ThirdPhaseEnd" && actor.Value.Id == 23200082) { + if (!isDead && hp > 0) { + Logger.Warning( + "[AISuppressUserValue] actorId:{ActorId}, key:{Key}, value:{Value}, hp:{Hp}, dead:{Dead}", + actor.Value.Id, node.Key, node.Value, hp, isDead + ); + return; + } + } + + // 超链接之树:23200081 正在变身替换时,不允许把自己算成 KanduraNormalDead + if (node.Key == "KanduraNormalDead" && actor.Value.Id == 23200081) { + if (actor.AiExtraData.GetValueOrDefault("__replacement_remove", 0) != 0) { + Logger.Warning( + "[AISuppressUserValue] actorId:{ActorId}, key:{Key}, value:{Value}, replacementRemove=1", + actor.Value.Id, node.Key, node.Value + ); + return; + } + } + + Logger.Warning( + "[AIUserValue] actorId:{ActorId}, key:{Key}, value:{Value}, hp:{Hp}, dead:{Dead}", + actor.Value.Id, node.Key, node.Value, hp, isDead + ); + actor.Field.UserValues[node.Key] = node.Value; } - private void ProcessNode(RideNode node) { } - private void ProcessNode(SetSlaveValueNode node) { } + private void ProcessNode(SetSlaveValueNode node) { + List slaves = new(); + + foreach (FieldNpc npc in actor.Field.EnumerateNpcs()) { + if (npc.ObjectId == actor.ObjectId || npc.IsDead) { + continue; + } + + if (npc.AiExtraData.GetValueOrDefault("__master_oid", 0) != actor.ObjectId) { + continue; + } + + slaves.Add(npc); + } + + if (slaves.Count == 0) { + return; + } + + void Apply(FieldNpc target) { + int value = node.IsRandom && node.Value > 0 ? Random.Shared.Next(0, node.Value + 1) : node.Value; - private void ProcessNode(SetMasterValueNode node) { } + if (node.IsModify) { + target.AiExtraData[node.Key] = target.AiExtraData.GetValueOrDefault(node.Key, 0) + value; + } else { + target.AiExtraData[node.Key] = value; + } + + if (node.IsKeepBattle) { + target.BattleState.KeepBattle = true; + } + } + + if (node.IsRandom) { + Apply(slaves[Random.Shared.Next(slaves.Count)]); + return; + } + + foreach (FieldNpc slave in slaves) { + Apply(slave); + } + } + + private void ProcessNode(SetMasterValueNode node) { + int masterOid = actor.AiExtraData.GetValueOrDefault("__master_oid", 0); + if (masterOid == 0) { + return; + } + + if (!actor.Field.TryGetActor(masterOid, out IActor? masterActor) || masterActor is not FieldNpc masterNpc || masterNpc.IsDead) { + return; + } + + int value = node.IsRandom && node.Value > 0 ? Random.Shared.Next(0, node.Value + 1) : node.Value; + + if (node.IsModify) { + masterNpc.AiExtraData[node.Key] = masterNpc.AiExtraData.GetValueOrDefault(node.Key, 0) + value; + } else { + masterNpc.AiExtraData[node.Key] = value; + } + + if (node.IsKeepBattle) { + masterNpc.BattleState.KeepBattle = true; + } + } private void ProcessNode(RunawayNode node) { if (!actor.Field.TryGetActor(actor.BattleState.TargetId, out IActor? target)) { @@ -570,11 +902,11 @@ private void ProcessNode(SetValueRangeTargetNode node) { if (node.IsModify) { if (npc.AiExtraData.TryGetValue(node.Key, out int oldValue)) { - actor.AiExtraData[node.Key] = oldValue + node.Value; + npc.AiExtraData[node.Key] = oldValue + node.Value; continue; } } - actor.AiExtraData[node.Key] = node.Value; + npc.AiExtraData[node.Key] = node.Value; } } @@ -590,14 +922,51 @@ private void ProcessNode(TriggerModifyUserValueNode node) { actor.Field.UserValues[node.Key] = node.Value; } - private void ProcessNode(RemoveSlavesNode node) { } + private void ProcessNode(RemoveSlavesNode node) { + List removeIds = new(); + + foreach (FieldNpc npc in actor.Field.EnumerateNpcs()) { + if (npc.ObjectId == actor.ObjectId) { + continue; + } + + if (npc.AiExtraData.GetValueOrDefault("__master_oid", 0) != actor.ObjectId) { + continue; + } + + if (node.IsKeepBattle) { + npc.BattleState.KeepBattle = true; + } + + removeIds.Add(npc.ObjectId); + } + + foreach (int objectId in removeIds) { + actor.Field.RemoveNpc(objectId, TimeSpan.FromMilliseconds(100)); + } + } private void ProcessNode(CreateRandomRoomNode node) { } private void ProcessNode(CreateInteractObjectNode node) { } private void ProcessNode(RemoveMeNode node) { - actor.Field.RemoveNpc(actor.ObjectId); + Logger.Warning( + "[AIRemoveMe] actorId:{ActorId}, hp:{Hp}, dead:{Dead}, replacementRemove:{ReplacementRemove}", + actor.Value.Id, + actor.Stats.Values[BasicAttribute.Health].Current, + actor.IsDead, + actor.AiExtraData.GetValueOrDefault("__replacement_remove", 0) +); + bool replacementRemove = actor.AiExtraData.GetValueOrDefault("__replacement_remove", 0) != 0; + + if (replacementRemove) { + actor.AiExtraData["__suppress_battle_end"] = 1; + actor.AiExtraData["__replacement_remove"] = 0; + } + + actor.BattleState.KeepBattle = false; + actor.Field.RemoveNpc(actor.ObjectId, TimeSpan.FromMilliseconds(100)); } private void ProcessNode(SuicideNode node) { @@ -606,13 +975,9 @@ private void ProcessNode(SuicideNode node) { private bool ProcessCondition(DistanceOverCondition node) { - if (actor.BattleState.Target is null) { - return false; - } - - float targetDistance = (actor.BattleState.Target.Position - actor.Position).LengthSquared(); - - return node.Value * node.Value > (int) targetDistance; + if (actor.BattleState.Target == null) return false; + float dist = (actor.BattleState.Target.Position - actor.Position).LengthSquared(); + return dist > node.Value * node.Value; } private bool ProcessCondition(CombatTimeCondition node) { @@ -620,13 +985,9 @@ private bool ProcessCondition(CombatTimeCondition node) { } private bool ProcessCondition(DistanceLessCondition node) { - if (actor.BattleState.Target is null) { - return false; - } - - float targetDistance = (actor.BattleState.Target.Position - actor.Position).LengthSquared(); - - return node.Value * node.Value < (int) targetDistance; + if (actor.BattleState.Target == null) return false; + float dist = (actor.BattleState.Target.Position - actor.Position).LengthSquared(); + return dist < node.Value * node.Value; } private bool ProcessCondition(SkillRangeCondition node) { @@ -663,11 +1024,43 @@ private bool ProcessCondition(ExtraDataCondition node) { } private bool ProcessCondition(SlaveCountCondition node) { - return false; + int count = 0; + + foreach (FieldNpc npc in actor.Field.EnumerateNpcs()) { + if (npc.ObjectId == actor.ObjectId || npc.IsDead) { + continue; + } + + if (npc.AiExtraData.GetValueOrDefault("__master_oid", 0) != actor.ObjectId) { + continue; + } + + if (node.UseSummonGroup && npc.AiExtraData.GetValueOrDefault("__summon_group", 0) != node.SummonGroup) { + continue; + } + + count++; + } + + return count == node.Count; } private bool ProcessCondition(SlaveCountOpCondition node) { - return false; + int count = 0; + + foreach (FieldNpc npc in actor.Field.EnumerateNpcs()) { + if (npc.ObjectId == actor.ObjectId || npc.IsDead) { + continue; + } + + if (npc.AiExtraData.GetValueOrDefault("__master_oid", 0) != actor.ObjectId) { + continue; + } + + count++; + } + + return PerformOperation(node.SlaveCountOp, node.SlaveCount, count); } private bool ProcessCondition(HpOverCondition node) { diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs index c3be812ce..15b1e8f8e 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs @@ -202,6 +202,8 @@ private bool ShouldKeepTarget() { target = targetPlayer; } else if (actor.Field.Npcs.TryGetValue(TargetId, out FieldNpc? targetNpc)) { target = targetNpc; + } else if (actor.Field.Mobs.TryGetValue(TargetId, out FieldNpc? targetMob)) { + target = targetMob; } @@ -215,6 +217,10 @@ private bool ShouldKeepTarget() { private int GetTargetType() { int friendlyType = actor.Value.Metadata.Basic.Friendly; + if (actor is FieldPet { OwnerId: > 0 }) { + friendlyType = 1; + } + if (friendlyType != 2 && TargetNode is not null && TargetType == NodeTargetType.HasAdditional) { friendlyType = TargetNode.Target switch { NodeAiTarget.Hostile => friendlyType == 0 ? 0 : 1, // enemies target players (0), friendlies target enemies (1) @@ -225,7 +231,9 @@ private int GetTargetType() { return friendlyType; } - + public void ForceTarget(IActor? target) { + Target = target; + } private void FindNewTarget() { int friendlyType = GetTargetType(); float sightSquared = actor.Value.Metadata.Distance.Sight; @@ -251,6 +259,12 @@ private void FindNewTarget() { } if (friendlyType == 1) { + foreach (FieldNpc npc in actor.Field.Mobs.Values) { + if (ShouldTargetActor(npc, sightSquared, sightHeightUp, sightHeightDown, ref nextTargetDistance, candidates)) { + nextTarget = npc; + } + } + foreach (FieldNpc npc in actor.Field.Npcs.Values) { if (npc.Value.Metadata.Basic.Friendly != 0) { continue; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs index 2eb377053..ce570eb6f 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/MovementStateTasks/MovementState.SkillCastTask.cs @@ -41,8 +41,17 @@ protected override void TaskFinished(bool isCompleted) { private void SkillCastFaceTarget(SkillRecord cast, IActor target, int faceTarget) { Vector3 offset = target.Position - actor.Position; - float distance = offset.LengthSquared(); - + float distance = offset.X * offset.X + offset.Y * offset.Y; + float vertical = MathF.Abs(offset.Z); + + // In MS2 data, many AI skill nodes use FaceTarget=0 while the skill's AutoTargeting.MaxDegree + // is a narrow cone. If we gate turning by MaxDegree (dot product), the NPC can end up casting + // while facing away (target behind the cone) and never correct its facing. + // + // To avoid "背对玩家放技能", we always rotate to face the current target when: + // - the motion requests FaceTarget, and + // - AutoTargeting distance/height constraints allow it. + // MaxDegree is ignored for *turning*. if (faceTarget != 1) { if (!cast.Motion.MotionProperty.FaceTarget || cast.Metadata.Data.AutoTargeting is null) { return; @@ -50,26 +59,28 @@ private void SkillCastFaceTarget(SkillRecord cast, IActor target, int faceTarget var autoTargeting = cast.Metadata.Data.AutoTargeting; - bool shouldFaceTarget = autoTargeting.MaxDistance == 0 || distance <= autoTargeting.MaxDistance; - shouldFaceTarget |= autoTargeting.MaxHeight == 0 || offset.Y <= autoTargeting.MaxHeight; + bool inRange = autoTargeting.MaxDistance == 0 || distance <= autoTargeting.MaxDistance * autoTargeting.MaxDistance; + inRange &= autoTargeting.MaxHeight == 0 || vertical <= autoTargeting.MaxHeight; + if (!inRange) { + return; + } - if (!shouldFaceTarget) { + if (distance < 0.0001f) { return; } distance = (float) Math.Sqrt(distance); offset *= (1 / distance); + } else { + if (distance < 0.0001f) { - float degreeCosine = (float) Math.Cos(autoTargeting.MaxDegree / 2); - float dot = Vector3.Dot(offset, actor.Transform.FrontAxis); - - shouldFaceTarget = autoTargeting.MaxDegree == 0 || dot >= degreeCosine; - - if (!shouldFaceTarget) { return; + } - } else { + + distance = (float) Math.Sqrt(distance); + offset *= (1 / distance); } @@ -104,9 +115,12 @@ private void SkillCast(NpcSkillCastTask task, int id, short level, long uid, byt } if (task.FacePos != new Vector3(0, 0, 0)) { - actor.Transform.LookTo(Vector3.Normalize(task.FacePos - actor.Position)); + actor.Transform.LookTo(task.FacePos - actor.Position); // safe: LookTo normalizes with guards } else if (actor.BattleState.Target is not null) { - SkillCastFaceTarget(cast, actor.BattleState.Target, task.FaceTarget); + // Hard guarantee: NPCs should always face their current battle target when casting. + // Some boss skills have MotionProperty.FaceTarget=false or narrow AutoTargeting degrees, + // which previously allowed casting while facing away. + actor.Transform.LookTo(actor.BattleState.Target.Position - actor.Position); // safe: LookTo normalizes with guards } CastTask = task; diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs index 6e53abf3d..05df5781f 100644 --- a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs +++ b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/SkillState.cs @@ -1,4 +1,4 @@ -using System.Numerics; +using System.Numerics; using Maple2.Model.Metadata; using Maple2.Server.Game.Model.Skill; using Maple2.Server.Game.Packets; @@ -54,7 +54,15 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att if (attackTargets.Count > targetIndex) { // if attack.direction == 3, use direction to target, if attack.direction == 0, use rotation maybe? cast.Position = actor.Position; - cast.Direction = Vector3.Normalize(attackTargets[targetIndex].Position - actor.Position); + Vector3 dir = attackTargets[targetIndex].Position - actor.Position; + if (float.IsNaN(dir.X) || float.IsNaN(dir.Y) || float.IsNaN(dir.Z) || + float.IsInfinity(dir.X) || float.IsInfinity(dir.Y) || float.IsInfinity(dir.Z) || + dir.LengthSquared() < 1e-6f) { + // Keep current facing if target is on top of caster. + cast.Direction = actor.Transform.FrontAxis; + } else { + cast.Direction = Vector3.Normalize(dir); + } } actor.Field.Broadcast(SkillDamagePacket.Target(cast, targets)); @@ -66,13 +74,23 @@ public void SkillCastAttack(SkillRecord cast, byte attackPoint, List att } - // Apply damage to targets server-side for NPC attacks - // Always use the attack range prism to resolve targets so spatial checks are respected - Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); + // Apply damage to targets server-side for NPC attacks. + // For player-owned combat pets, prefer the current battle target directly. + // Many pet skills are authored with client-side target metadata that does not + // line up with our generic NPC target query, which can cause the owner/player + // to be selected instead of the hostile mob. var resolvedTargets = new List(); int queryLimit = attack.TargetCount > 0 ? attack.TargetCount : 1; - foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range, queryLimit)) { - resolvedTargets.Add(target); + + if (actor is FieldPet { OwnerId: > 0 } ownedPet && ownedPet.BattleState.Target is FieldNpc hostileTarget && !hostileTarget.IsDead) { + resolvedTargets.Add(hostileTarget); + } + + if (resolvedTargets.Count == 0) { + Tools.Collision.Prism attackPrism = attack.Range.GetPrism(actor.Position, actor.Rotation.Z); + foreach (IActor target in actor.Field.GetTargets(actor, [attackPrism], attack.Range, queryLimit)) { + resolvedTargets.Add(target); + } } if (resolvedTargets.Count > 0) { diff --git a/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs b/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs index 4208ae688..d6c567ea7 100644 --- a/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs +++ b/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs @@ -197,6 +197,15 @@ public override void Update(long tickCount) { playersListeningToDebug = playersListeningToDebugNow; + // Defensive: if any upstream system produced a non-finite position, recover instead of + // crashing during packet serialization (Vector3 -> Vector3S conversion can overflow on NaN). + if (!float.IsFinite(Position.X) || !float.IsFinite(Position.Y) || !float.IsFinite(Position.Z)) { + // Recover instead of crashing during packet serialization. + Position = Origin; + Navigation?.ClearPath(); + SendControl = true; + } + if (SendControl && !IsDead) { SequenceCounter++; Field.BroadcastNpcControl(this); @@ -510,6 +519,7 @@ private void HandleDamageDealers() { DropIndividualLoot(player); GiveExp(player); + player.Session.Survival.AddPassExp(player.Session.Survival.GetPassExpForNpc(this)); player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId); foreach (string tag in Value.Metadata.Basic.MainTags) { @@ -532,6 +542,7 @@ private void HandleDamageDealers() { } GiveExp(player); + player.Session.Survival.AddPassExp(player.Session.Survival.GetPassExpForNpc(this)); player.Session.ConditionUpdate(ConditionType.npc, codeLong: Value.Id, targetLong: Field.MapId); foreach (string tag in Value.Metadata.Basic.MainTags) { diff --git a/Maple2.Server.Game/Model/Field/Actor/FieldPlayer.cs b/Maple2.Server.Game/Model/Field/Actor/FieldPlayer.cs index ce3b45acf..999ae7b61 100644 --- a/Maple2.Server.Game/Model/Field/Actor/FieldPlayer.cs +++ b/Maple2.Server.Game/Model/Field/Actor/FieldPlayer.cs @@ -5,11 +5,13 @@ using Maple2.Model.Metadata; using Maple2.Model.Metadata.FieldEntity; using Maple2.Server.Game.Manager; +using Maple2.Server.Game.Manager.Field; using Maple2.Server.Game.Model.Skill; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; using Maple2.Tools.Collision; using Maple2.Tools.Scheduler; +using Maple2.Server.Game.Util; namespace Maple2.Server.Game.Model; @@ -345,6 +347,14 @@ public void OnStateSync(StateSync stateSync) { // TODO: Any more condition states? } + if (Field is HomeFieldManager) { + foreach (Plot plot in Field.Plots.Values) { + foreach (PlotCube cube in plot.Cubes.Values) { + HousingFunctionFurnitureRegistry.TryTriggerTrap(Session, cube); + } + } + } + Field.EnsurePlayerPosition(this); return; diff --git a/Maple2.Server.Game/Model/Field/Buff.cs b/Maple2.Server.Game/Model/Field/Buff.cs index 0cba6abb3..3de375264 100644 --- a/Maple2.Server.Game/Model/Field/Buff.cs +++ b/Maple2.Server.Game/Model/Field/Buff.cs @@ -208,6 +208,14 @@ private void ApplyRecovery() { updated.Add(BasicAttribute.Stamina); } + if (record.HpAmount > 0 && Caster is FieldPlayer casterPlayer) { + casterPlayer.Session.Dungeon.UserRecord?.AccumulationRecords.AddOrUpdate( + DungeonAccumulationRecordType.TotalHealing, + record.HpAmount, + (_, current) => (int) Math.Min((long) current + record.HpAmount, int.MaxValue) + ); + } + if (updated.Count > 0) { Field.Broadcast(StatsPacket.Update(Owner, updated.ToArray())); } @@ -244,6 +252,13 @@ private void ApplyDotDamage() { Field.Broadcast(SkillDamagePacket.DotDamage(record)); if (record.RecoverHp != 0) { Caster.Stats.Values[BasicAttribute.Health].Add(record.RecoverHp); + if (record.RecoverHp > 0 && Caster is FieldPlayer casterPlayer) { + casterPlayer.Session.Dungeon.UserRecord?.AccumulationRecords.AddOrUpdate( + DungeonAccumulationRecordType.TotalHealing, + record.RecoverHp, + (_, current) => (int) Math.Min((long) current + record.RecoverHp, int.MaxValue) + ); + } Field.Broadcast(StatsPacket.Update(Caster, BasicAttribute.Health)); } } diff --git a/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs b/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs index fa7b3c5d7..c98bb5ffa 100644 --- a/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs +++ b/Maple2.Server.Game/Model/Field/Entity/FieldSkill.cs @@ -29,7 +29,16 @@ public class FieldSkill : FieldEntity { private readonly long endTick; public long NextTick { get; private set; } private readonly ILogger logger = Log.ForContext(); - + private static Vector3 SanitizePosition(Vector3 v) { + if (float.IsNaN(v.X) || float.IsNaN(v.Y) || float.IsNaN(v.Z) || + float.IsInfinity(v.X) || float.IsInfinity(v.Y) || float.IsInfinity(v.Z)) { + return Vector3.Zero; + } + v.X = Math.Clamp(v.X, short.MinValue, short.MaxValue); + v.Y = Math.Clamp(v.Y, short.MinValue, short.MaxValue); + v.Z = Math.Clamp(v.Z, short.MinValue, short.MaxValue); + return v; + } private ByteWriter GetDamagePacket(DamageRecord record) => Source switch { SkillSource.Cube => SkillDamagePacket.Tile(record), _ => SkillDamagePacket.Region(record), @@ -126,13 +135,13 @@ public override void Update(long tickCount) { SkillMetadataAttack attack = record.Attack; record.TargetUid++; var damage = new DamageRecord(record.Metadata, attack) { - CasterId = ObjectId, - OwnerId = ObjectId, - SkillId = Value.Id, - Level = Value.Level, + CasterId = record.Caster.ObjectId, + OwnerId = record.Caster.ObjectId, + SkillId = record.SkillId, + Level = record.Level, MotionPoint = record.MotionPoint, AttackPoint = record.AttackPoint, - Position = Position, + Position = SanitizePosition(Position), Direction = Rotation, }; var targetRecords = new List(); @@ -215,6 +224,7 @@ public override void Update(long tickCount) { if (targetRecords.Count > 0) { Field.Broadcast(SkillDamagePacket.Target(record, targetRecords)); Field.Broadcast(GetDamagePacket(damage)); + Field.Broadcast(SkillDamagePacket.Damage(damage)); } Caster.ApplyEffects(attack.SkillsOnDamage, Caster, damage, targets: targets); diff --git a/Maple2.Server.Game/Model/Field/Entity/FieldTrigger.cs b/Maple2.Server.Game/Model/Field/Entity/FieldTrigger.cs index 638cec295..647f0fddc 100644 --- a/Maple2.Server.Game/Model/Field/Entity/FieldTrigger.cs +++ b/Maple2.Server.Game/Model/Field/Entity/FieldTrigger.cs @@ -28,6 +28,13 @@ public FieldTrigger(FieldManager field, int objectId, TriggerModel value) : base nextTick = field.FieldTick; } + public FieldTrigger(FieldManager field, int objectId, TriggerModel value, Trigger.Helpers.Trigger parsedTrigger) : base(field, objectId, value) { + Context = new TriggerContext(this); + trigger = parsedTrigger; + nextState = new TriggerState(trigger, Context); + nextTick = field.FieldTick; + } + public List GetStates(string[] names) { if (names.Length == 0) { throw new ArgumentException("At least one state name must be provided."); diff --git a/Maple2.Server.Game/Model/Stats.cs b/Maple2.Server.Game/Model/Stats.cs index 651ff8ac7..11ee8c5d0 100644 --- a/Maple2.Server.Game/Model/Stats.cs +++ b/Maple2.Server.Game/Model/Stats.cs @@ -120,8 +120,12 @@ public void Total() { } foreach (Stat stat in specialValues.Values) { - long rateBonus = (long) (stat.Rate * (stat.Base + (stat.Total - stat.Base))); - stat.AddTotal(rateBonus); + // Special attributes such as Boss Damage / Element Damage / Piercing are stored as + // additive thousandths in Total rather than multiplicative Rate-based stats. + if (stat.Rate != 0) { + long rateBonus = (long) (stat.Rate * (stat.Base + (stat.Total - stat.Base))); + stat.AddTotal(rateBonus); + } } } @@ -197,8 +201,19 @@ public void AddTotal(BasicOption option) { } public void AddTotal(SpecialOption option) { - AddTotal((int) option.Value); - Rate += option.Rate; + if (option.Value != 0) { + AddTotal((long) Math.Round(option.Value)); + } + if (option.Rate != 0) { + AddTotal((long) Math.Round(option.Rate * 1000f)); + } + } + + public void AddScaledTotal(float amount) { + if (amount == 0) { + return; + } + AddTotal((long) Math.Round(amount * 1000f)); } public void AddRate(float rate) { diff --git a/Maple2.Server.Game/PacketHandlers/ChangeAttributesHandler.cs b/Maple2.Server.Game/PacketHandlers/ChangeAttributesHandler.cs index 330a24047..3cbcb59f4 100644 --- a/Maple2.Server.Game/PacketHandlers/ChangeAttributesHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/ChangeAttributesHandler.cs @@ -204,10 +204,14 @@ private static void HandleForceFill(GameSession session, IByteReader packet) { // It needs to be epic or better armor and accessories at level 50 and above. private static bool IsValidItem(Item item) { + if (item.Type.IsCombatPet) { + return item.Rarity is >= 1 and <= Constant.ChangeAttributesMaxRarity; + } + if (item.Rarity is < Constant.ChangeAttributesMinRarity or > Constant.ChangeAttributesMaxRarity) { return false; } - if (!item.Type.IsWeapon && !item.Type.IsArmor && !item.Type.IsAccessory && !item.Type.IsCombatPet) { + if (!item.Type.IsWeapon && !item.Type.IsArmor && !item.Type.IsAccessory) { return false; } if (item.Metadata.Limit.Level < Constant.ChangeAttributesMinLevel) { diff --git a/Maple2.Server.Game/PacketHandlers/ChangeAttributesScrollHandler.cs b/Maple2.Server.Game/PacketHandlers/ChangeAttributesScrollHandler.cs index 4635be90c..13bae3c0a 100644 --- a/Maple2.Server.Game/PacketHandlers/ChangeAttributesScrollHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/ChangeAttributesScrollHandler.cs @@ -216,15 +216,21 @@ private static void HandleSelect(GameSession session, IByteReader packet) { private ChangeAttributesScrollError IsCompatibleScroll(Item item, Item scroll, out ItemRemakeScrollMetadata? metadata) { metadata = null; - if (item.Rarity is < Constant.ChangeAttributesMinRarity or > Constant.ChangeAttributesMaxRarity) { - return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_rank; + if (item.Type.IsPet) { + if (item.Rarity is < 1 or > Constant.ChangeAttributesMaxRarity) { + return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_rank; + } + } else { + if (item.Rarity is < Constant.ChangeAttributesMinRarity or > Constant.ChangeAttributesMaxRarity) { + return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_rank; + } + if (item.Metadata.Limit.Level < Constant.ChangeAttributesMinLevel) { + return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_level; + } } if (!item.Type.IsWeapon && item.Type is { IsArmor: false, IsAccessory: false, IsPet: false }) { return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_slot; } - if (item.Metadata.Limit.Level < Constant.ChangeAttributesMinLevel) { - return ChangeAttributesScrollError.s_itemremake_scroll_error_impossible_level; - } // Validate scroll conditions if (!int.TryParse(scroll.Metadata.Function?.Parameters, out int remakeId)) { diff --git a/Maple2.Server.Game/PacketHandlers/FunctionCubeHandler.cs b/Maple2.Server.Game/PacketHandlers/FunctionCubeHandler.cs index 0ea79dfe6..812390e53 100644 --- a/Maple2.Server.Game/PacketHandlers/FunctionCubeHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/FunctionCubeHandler.cs @@ -1,4 +1,6 @@ -using Maple2.Database.Storage; +using System.Linq; +using System.Numerics; +using Maple2.Database.Storage; using Maple2.Model.Enum; using Maple2.Model.Game; using Maple2.Model.Metadata; @@ -9,10 +11,14 @@ using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; +using Serilog; namespace Maple2.Server.Game.PacketHandlers; public class FunctionCubeHandler : FieldPacketHandler { + private static readonly ILogger FunctionCubeLogger = Log.Logger.ForContext(); + #region Autofac Autowired // ReSharper disable MemberCanBePrivate.Global @@ -50,6 +56,18 @@ private void HandleUseCube(GameSession session, IByteReader packet) { return; } + PlotCube? plotCube = session.Field?.Plots.Values + .SelectMany(plot => plot.Cubes.Values) + .FirstOrDefault(cube => cube.Id == fieldInteract.CubeId); + + FunctionCubeLogger.Information("FunctionCube.Use interactId={InteractId} unk={Unk} cubeId={CubeId} objectCode={ObjectCode} controlType={ControlType} state={State} plotItemId={PlotItemId}", + interactId, unk, fieldInteract.CubeId, fieldInteract.InteractCube.Metadata.Id, fieldInteract.Value.ControlType, fieldInteract.InteractCube.State, plotCube?.ItemId ?? 0); + + if (plotCube is not null && HousingFunctionFurnitureRegistry.TryHandleUse(session, plotCube, fieldInteract)) { + FunctionCubeLogger.Information("Housing furniture registry handled interactId={InteractId} plotItemId={PlotItemId}", interactId, plotCube.ItemId); + return; + } + switch (fieldInteract.Value.ControlType) { case InteractCubeControlType.Nurturing: if (fieldInteract.InteractCube.Nurturing is not null) { @@ -65,6 +83,10 @@ private void HandleUseCube(GameSession session, IByteReader packet) { session.Mastery.Gather(fieldFunctionInteract); } break; + case InteractCubeControlType.SpawnNPC: + case InteractCubeControlType.InstallNPC: + HandleSpawnNpcFurniture(session, fieldInteract); + break; default: if (fieldInteract.InteractCube.State is InteractCubeState.InUse && fieldInteract.InteractCube.InteractingCharacterId != session.CharacterId) { return; @@ -79,6 +101,37 @@ private void HandleUseCube(GameSession session, IByteReader packet) { } } + + private void HandleSpawnNpcFurniture(GameSession session, FieldFunctionInteract fieldInteract) { + if (session.Field is null) { + return; + } + + FieldFunctionInteract? currentInteract = session.Field.TryGetFieldFunctionInteract(fieldInteract.InteractCube.Id); + if (currentInteract == null) { + return; + } + + if (currentInteract.InteractCube.State is InteractCubeState.InUse && currentInteract.InteractCube.InteractingCharacterId != session.CharacterId) { + return; + } + + PlotCube? plotCube = session.Field.Plots.Values + .SelectMany(plot => plot.Cubes.Values) + .FirstOrDefault(cube => cube.Id == currentInteract.CubeId); + if (plotCube is null) { + Logger.Warning("Furniture cube runtime not found for interact cube {InteractId}", currentInteract.InteractCube.Id); + return; + } + + currentInteract.InteractCube.State = InteractCubeState.InUse; + currentInteract.InteractCube.InteractingCharacterId = session.CharacterId; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(currentInteract.InteractCube)); + session.Field.Broadcast(FunctionCubePacket.UseFurniture(session.CharacterId, currentInteract.InteractCube)); + + HousingFunctionFurnitureRegistry.Materialize(session.Field, plotCube); + } + private void HandleNurturing(GameSession session, FieldFunctionInteract fieldCube) { if (session.Field is null) return; diff --git a/Maple2.Server.Game/PacketHandlers/GuildHandler.cs b/Maple2.Server.Game/PacketHandlers/GuildHandler.cs index f54052620..6483e176f 100644 --- a/Maple2.Server.Game/PacketHandlers/GuildHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/GuildHandler.cs @@ -39,8 +39,8 @@ private enum Command : byte { SendApplication = 80, CancelApplication = 81, RespondApplication = 82, - Unknown83 = 83, - ListApplications = 84, + ListApplications = 83, + ListAppliedGuilds = 84, SearchGuilds = 85, SearchGuildName = 86, UseBuff = 88, @@ -129,6 +129,9 @@ public override void Handle(GameSession session, IByteReader packet) { case Command.ListApplications: HandleListApplications(session); return; + case Command.ListAppliedGuilds: + HandleListAppliedGuilds(session); + return; case Command.SearchGuilds: HandleSearchGuilds(session, packet); return; @@ -579,8 +582,48 @@ private void HandleIncreaseCapacity(GameSession session) { } private void HandleUpdateRank(GameSession session, IByteReader packet) { - packet.ReadByte(); + if (session.Guild.Guild == null) { + return; + } + + byte rankId = packet.ReadByte(); var rank = packet.ReadClass(); + rank.Id = rankId; + + if (!session.Guild.HasPermission(session.CharacterId, GuildPermission.EditRank)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_no_authority)); + return; + } + if (rankId >= session.Guild.Guild.Ranks.Count) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_invalid_grade_index)); + return; + } + if (string.IsNullOrWhiteSpace(rank.Name) || rank.Name.Length > Constant.GuildNameLengthMax) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_invalid_grade_data)); + return; + } + + session.Guild.Guild.Ranks[rankId].Name = rank.Name; + session.Guild.Guild.Ranks[rankId].Permission = rank.Permission; + + using GameStorage.Request db = session.GameStorage.Context(); + if (!db.SaveGuild(session.Guild.Guild)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + foreach (GuildMember member in session.Guild.Guild.Members.Values) { + if (!session.FindSession(member.CharacterId, out GameSession? other) || other.Guild.Guild == null) { + continue; + } + if (rankId < other.Guild.Guild.Ranks.Count) { + other.Guild.Guild.Ranks[rankId].Name = rank.Name; + other.Guild.Guild.Ranks[rankId].Permission = rank.Permission; + } + other.Send(GuildPacket.NotifyUpdateRank(new InterfaceText("s_guild_change_grade_sucess", false), rank)); + } + + session.Send(GuildPacket.UpdateRank(rank)); } private void HandleUpdateFocus(GameSession session, IByteReader packet) { @@ -595,28 +638,187 @@ private void HandleSendMail(GameSession session, IByteReader packet) { private void HandleSendApplication(GameSession session, IByteReader packet) { long guildId = packet.ReadLong(); + if (session.Guild.Guild != null) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_has_guild)); + return; + } + + using GameStorage.Request db = session.GameStorage.Context(); + Guild? guild = db.GetGuild(guildId); + if (guild == null) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_null_guild)); + return; + } + + GuildApplication? application = db.CreateGuildApplication(session.PlayerInfo, guildId, session.CharacterId); + if (application == null) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + session.Send(GuildPacket.SendApplication(application.Id, guild.Name)); + + foreach (GuildMember member in guild.Members.Values) { + if (!session.FindSession(member.CharacterId, out GameSession? guildSession)) { + continue; + } + + GuildRank? rank = guild.Ranks.FirstOrDefault(x => x.Id == member.Rank); + if (rank == null) { + continue; + } + + if (!rank.Permission.HasFlag(GuildPermission.InviteMembers)) { + continue; + } + + guildSession.Send(GuildPacket.ReceiveApplication(application)); + } } private void HandleCancelApplication(GameSession session, IByteReader packet) { long applicationId = packet.ReadLong(); + using GameStorage.Request db = session.GameStorage.Context(); + GuildApplication? application = db.GetGuildApplication(session.PlayerInfo, applicationId); + if (application == null || application.Applicant.CharacterId != session.CharacterId) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + if (!db.DeleteGuildApplication(applicationId)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + session.Send(GuildPacket.CancelApplication(applicationId, application.Guild.Name)); } private void HandleRespondApplication(GameSession session, IByteReader packet) { + static GuildMember CloneGuildMember(GuildMember member) { + return new GuildMember { + GuildId = member.GuildId, + Info = member.Info.Clone(), + Message = member.Message, + Rank = member.Rank, + WeeklyContribution = member.WeeklyContribution, + TotalContribution = member.TotalContribution, + DailyDonationCount = member.DailyDonationCount, + JoinTime = member.JoinTime, + CheckinTime = member.CheckinTime, + DonationTime = member.DonationTime, + }; + } + long applicationId = packet.ReadLong(); bool accepted = packet.ReadBool(); + if (session.Guild.Guild == null) { + return; + } + if (!session.Guild.HasPermission(session.CharacterId, GuildPermission.InviteMembers)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_no_authority)); + return; + } + + using GameStorage.Request db = session.GameStorage.Context(); + GuildApplication? application = db.GetGuildApplication(session.PlayerInfo, applicationId); + if (application == null || application.Guild.Id != session.Guild.Id) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + if (accepted) { + GuildMember? newMember = db.CreateGuildMember(session.Guild.Id, application.Applicant); + if (newMember == null) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_fail_addmember)); + return; + } + + if (!db.DeleteGuildApplication(applicationId)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + GuildMember[] currentMembers = session.Guild.Guild.Members.Values.ToArray(); + session.Guild.Guild.Members.TryAdd(newMember.CharacterId, newMember); + + // Refresh the guild master's member list immediately even if self session lookup fails. + session.Send(GuildPacket.Joined(session.PlayerName, CloneGuildMember(newMember))); + + foreach (GuildMember member in currentMembers) { + if (member.CharacterId == session.CharacterId) { + continue; + } + if (!session.FindSession(member.CharacterId, out GameSession? other)) { + continue; + } + + other.Guild.AddMember(session.PlayerName, CloneGuildMember(newMember)); + } + + if (session.FindSession(newMember.CharacterId, out GameSession? applicantSession)) { + if (applicantSession.Guild.Guild == null) { + GuildInfoResponse guildInfo = applicantSession.World.GuildInfo(new GuildInfoRequest { + GuildId = session.Guild.Id, + }); + if (guildInfo.Guild != null) { + applicantSession.Guild.SetGuild(guildInfo.Guild); + } + } + + if (applicantSession.Guild.Guild != null && applicantSession.Guild.GetMember(newMember.CharacterId) == null) { + applicantSession.Guild.AddMember(session.PlayerName, CloneGuildMember(newMember)); + } + applicantSession.Guild.Load(); + applicantSession.Send(GuildPacket.ListAppliedGuilds(db.GetGuildApplicationsByApplicant(applicantSession.PlayerInfo, applicantSession.CharacterId))); + } + + session.Send(GuildPacket.ListApplications(db.GetGuildApplications(session.PlayerInfo, session.Guild.Id))); + } else { + if (!db.DeleteGuildApplication(applicationId)) { + session.Send(GuildPacket.Error(GuildError.s_guild_err_unknown)); + return; + } + + session.Send(GuildPacket.ListApplications(db.GetGuildApplications(session.PlayerInfo, session.Guild.Id))); + if (session.FindSession(application.Applicant.CharacterId, out GameSession? applicantSession)) { + applicantSession.Send(GuildPacket.ListAppliedGuilds(db.GetGuildApplicationsByApplicant(applicantSession.PlayerInfo, applicantSession.CharacterId))); + } + } + + session.Send(GuildPacket.RespondApplication(applicationId, application.Guild.Name, accepted)); } private void HandleListApplications(GameSession session) { + using GameStorage.Request db = session.GameStorage.Context(); + + if (session.Guild.Guild == null) { + session.Send(GuildPacket.ListApplications(Array.Empty())); + return; + } + session.Send(GuildPacket.ListApplications( + db.GetGuildApplications(session.PlayerInfo, session.Guild.Id) + )); } + private void HandleListAppliedGuilds(GameSession session) { + using GameStorage.Request db = session.GameStorage.Context(); + session.Send(GuildPacket.ListAppliedGuilds( + db.GetGuildApplicationsByApplicant(session.PlayerInfo, session.CharacterId) + )); + } private void HandleSearchGuilds(GameSession session, IByteReader packet) { var focus = packet.Read(); - packet.ReadInt(); // 1 + packet.ReadInt(); + + using GameStorage.Request db = session.GameStorage.Context(); + session.Send(GuildPacket.ListGuilds(db.SearchGuilds(session.PlayerInfo, focus: focus))); } private void HandleSearchGuildName(GameSession session, IByteReader packet) { string guildName = packet.ReadUnicodeString(); + + using GameStorage.Request db = session.GameStorage.Context(); + session.Send(GuildPacket.ListGuilds(db.SearchGuilds(session.PlayerInfo, guildName: guildName))); } private void HandleUseBuff(GameSession session, IByteReader packet) { diff --git a/Maple2.Server.Game/PacketHandlers/HomeActionHandler.cs b/Maple2.Server.Game/PacketHandlers/HomeActionHandler.cs index 7b67d0d0c..cb4e707e4 100644 --- a/Maple2.Server.Game/PacketHandlers/HomeActionHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/HomeActionHandler.cs @@ -10,10 +10,14 @@ using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; +using Serilog; namespace Maple2.Server.Game.PacketHandlers; public class HomeActionHandler : FieldPacketHandler { + private static readonly ILogger HomeActionLogger = Log.Logger.ForContext(); + public override RecvOp OpCode => RecvOp.HomeAction; private enum Command : byte { @@ -184,6 +188,12 @@ private void HandleChangeNoticeSettings(GameSession session, IByteReader packet) cube.Interact.NoticeSettings.Distance = packet.ReadByte(); session.Field?.Broadcast(HomeActionPacket.SendCubeNoticeSettings(cube, editing: false)); + + if (HousingFunctionFurnitureRegistry.IsSmartComputer(cube)) { + string result = HousingFunctionFurnitureRegistry.ApplyComputerScript(session, cube); + session.Send(HomeActionPacket.HostAlarm(result)); + } + session.Housing.SaveHome(); } @@ -200,6 +210,16 @@ private void HandleConfigurableSettings(GameSession session, IByteReader packet) return; } + HomeActionLogger.Information("HomeAction.SendConfigurableSettings coord={Coord} cubeId={CubeId} itemId={ItemId} hasNotice={HasNotice} hasPortal={HasPortal} isSmartComputer={IsSmartComputer}", + coord, cube.Id, cube.ItemId, cube.Interact?.NoticeSettings is not null, cube.Interact?.PortalSettings is not null, HousingFunctionFurnitureRegistry.IsSmartComputer(cube)); + + if (HousingFunctionFurnitureRegistry.IsSmartComputer(cube)) { + if (!HousingFunctionFurnitureRegistry.OpenSmartComputerEditor(session, cube)) { + Logger.Warning("Failed to open smart computer editor at {0}", coord); + } + return; + } + if (cube.Interact?.PortalSettings is not null) { List otherPortalsNames = plot.Cubes.Values .Where(x => x.ItemId is Constant.InteriorPortalCubeId && x.Id != cube.Id) diff --git a/Maple2.Server.Game/PacketHandlers/ItemUseHandler.cs b/Maple2.Server.Game/PacketHandlers/ItemUseHandler.cs index d0dddf31b..a313b194d 100644 --- a/Maple2.Server.Game/PacketHandlers/ItemUseHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/ItemUseHandler.cs @@ -44,6 +44,18 @@ public override void Handle(GameSession session, IByteReader packet) { return; } + if (session.Survival.TryUseGoldPassActivationItem(item)) { + return; + } + + if (session.Survival.TryUsePassExpItem(item)) { + return; + } + + if (session.Survival.TryUseSkinItem(item)) { + return; + } + switch (item.Metadata.Function?.Type) { case ItemFunction.BlueprintImport: HandleBlueprintImport(session, item); diff --git a/Maple2.Server.Game/PacketHandlers/PetHandler.cs b/Maple2.Server.Game/PacketHandlers/PetHandler.cs index e1e29789e..ef73eef4a 100644 --- a/Maple2.Server.Game/PacketHandlers/PetHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/PetHandler.cs @@ -1,4 +1,6 @@ -using Maple2.Model.Enum; +using System.Collections.Concurrent; +using Maple2.Model.Enum; +using Maple2.Model.Error; using Maple2.Model.Game; using Maple2.PacketLib.Tools; using Maple2.Server.Core.Constants; @@ -7,10 +9,21 @@ using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Database.Storage; +using Maple2.Model.Metadata; namespace Maple2.Server.Game.PacketHandlers; public class PetHandler : FieldPacketHandler { + private sealed class DailyFusionBonusState { + public DateOnly Day; + public int UsedCount; + } + + private const double StrengthenedBattlePetExpScale = 1.5d; + private const double DailyFusionBonusRate = 0.125d; + private static readonly ConcurrentDictionary DailyFusionBonusByCharacter = new(); + public override RecvOp OpCode => RecvOp.RequestPet; private enum Command : byte { @@ -127,10 +140,99 @@ private void HandleUpdateLootConfig(GameSession session, IByteReader packet) { private void HandleFusion(GameSession session, IByteReader packet) { long petUid = packet.ReadLong(); short count = packet.ReadShort(); + + var fodderUids = new List(count); for (int i = 0; i < count; i++) { - packet.ReadLong(); // fodder uid + fodderUids.Add(packet.ReadLong()); packet.ReadInt(); // count } + + Item? pet = session.Item.Inventory.Get(petUid, InventoryType.Pets); + if (pet?.Pet == null) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + long gainedExp = 0; + int remainingFusionBonuses = GetRemainingFusionBonuses(session); + lock (session.Item) { + foreach (long fodderUid in fodderUids) { + if (fodderUid == petUid) { + continue; + } + + Item? fodder = session.Item.Inventory.Get(fodderUid, InventoryType.Pets); + if (fodder?.Pet == null) { + continue; + } + + long materialExp = GetFusionMaterialExp(fodder); + if (remainingFusionBonuses > 0) { + long bonusExp = (long) Math.Floor(materialExp * DailyFusionBonusRate); + materialExp += bonusExp; + ConsumeFusionBonus(session); + remainingFusionBonuses--; + } + + gainedExp += materialExp; + if (session.Item.Inventory.Remove(fodderUid, out Item? removed)) { + session.Item.Inventory.Discard(removed, commit: true); + } + } + } + + if (gainedExp <= 0) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + pet.Pet.Exp += gainedExp; + + bool leveled = false; + while (pet.Pet.Level < Constant.PetMaxLevel) { + long requiredExp = GetRequiredPetExp(pet.Pet.Level, pet); + if (pet.Pet.Exp < requiredExp) { + break; + } + + pet.Pet.Exp -= requiredExp; + pet.Pet.Level++; + leveled = true; + } + + using (GameStorage.Request db = session.GameStorage.Context()) { + db.UpdateItem(pet); + } + + session.Send(ItemInventoryPacket.UpdateItem(pet)); + session.Send(PetPacket.PetInfo(session.Player.ObjectId, pet)); + FieldPet? summonedPet = GetSummonedFieldPet(session, pet.Uid); + if (summonedPet != null) { + session.Send(PetPacket.Fusion(summonedPet)); + if (leveled) { + session.Send(PetPacket.LevelUp(summonedPet)); + } + } else { + session.Send(PetPacket.Fusion(session.Player.ObjectId, pet)); + if (leveled) { + session.Send(PetPacket.LevelUp(session.Player.ObjectId, pet)); + } + session.Send(PetPacket.FusionCount(GetRemainingFusionBonuses(session))); + } + } + + private static FieldPet? GetSummonedFieldPet(GameSession session, long petUid) { + if (session.Field == null) { + return null; + } + + foreach (FieldPet fieldPet in session.Field.Pets.Values) { + if (fieldPet.OwnerId == session.Player.ObjectId && fieldPet.Pet.Uid == petUid) { + return fieldPet; + } + } + + return null; } private void HandleAttack(GameSession session, IByteReader packet) { @@ -144,14 +246,181 @@ private void HandleUnknown16(GameSession session, IByteReader packet) { private void HandleEvolve(GameSession session, IByteReader packet) { long petUid = packet.ReadLong(); + + Item? pet = session.Item.Inventory.Get(petUid, InventoryType.Pets); + if (pet?.Pet == null) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + int requiredPoints = GetRequiredEvolvePoints(pet.Rarity); + if (pet.Pet.EvolvePoints < requiredPoints) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + pet.Pet.EvolvePoints -= requiredPoints; + using (GameStorage.Request db = session.GameStorage.Context()) { + db.UpdateItem(pet); + } + session.Send(ItemInventoryPacket.UpdateItem(pet)); + session.Send(PetPacket.EvolvePoints(session.Player.ObjectId, pet)); } private void HandleEvolvePoints(GameSession session, IByteReader packet) { long petUid = packet.ReadLong(); short count = packet.ReadShort(); + + var fodderUids = new List(count); for (int i = 0; i < count; i++) { - packet.ReadLong(); // fodder uid + fodderUids.Add(packet.ReadLong()); } + + Item? pet = session.Item.Inventory.Get(petUid, InventoryType.Pets); + if (pet?.Pet == null) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + int gainedPoints = 0; + lock (session.Item) { + foreach (long fodderUid in fodderUids) { + if (fodderUid == petUid) { + continue; + } + + Item? fodder = session.Item.Inventory.Get(fodderUid, InventoryType.Pets); + if (fodder?.Pet == null) { + continue; + } + + gainedPoints += Math.Max(1, fodder.Rarity); + if (session.Item.Inventory.Remove(fodderUid, out Item? removed)) { + session.Item.Inventory.Discard(removed, commit: true); + } + } + } + + if (gainedPoints <= 0) { + session.Send(PetPacket.Error(PetError.s_common_error_unknown)); + return; + } + + pet.Pet.EvolvePoints += gainedPoints; + using (GameStorage.Request db = session.GameStorage.Context()) { + db.UpdateItem(pet); + } + session.Send(ItemInventoryPacket.UpdateItem(pet)); + session.Send(PetPacket.EvolvePoints(session.Player.ObjectId, pet)); + } + + private static long GetRequiredPetExp(Item pet) { + int levelRequirement = pet.Metadata.Limit.Level; + int rarity = pet.Rarity; + + long baseExp = (rarity, levelRequirement) switch { + (1, 50) => 22000L, + (2, 50) => 55000L, + (3, 50) => 90000L, + (3, 60) => 135000L, + (4, 50) => 90000L, + (4, 60) => 135000L, + (_, >= 60) => 135000L, + _ => rarity switch { + <= 1 => 22000L, + 2 => 55000L, + 3 => 90000L, + _ => 90000L, + } + }; + + return (long) Math.Round(baseExp * GetBattlePetExpScale(pet)); + } + + private static long GetFusionBaseExp(Item pet) { + int levelRequirement = pet.Metadata.Limit.Level; + int rarity = pet.Rarity; + + long baseExp = (rarity, levelRequirement) switch { + (1, 50) => 1500L, + (2, 50) => 3000L, + (3, 50) => 12000L, + (3, 60) => 18000L, + (4, 50) => 24000L, + (4, 60) => 36000L, + (_, >= 60) => 18000L, + _ => rarity switch { + <= 1 => 1500L, + 2 => 3000L, + 3 => 12000L, + _ => 24000L, + } + }; + + return (long) Math.Round(baseExp * GetBattlePetExpScale(pet)); + } + + private static long GetFusionMaterialExp(Item pet) { + if (pet.Pet == null) { + return 0L; + } + + long perLevelExp = GetRequiredPetExp(pet); + long investedExp = (Math.Max(1, (int) pet.Pet.Level) - 1L) * perLevelExp + Math.Max(0L, pet.Pet.Exp); + long materialExp = GetFusionBaseExp(pet) + (long) Math.Floor(investedExp * 0.8d); + return Math.Max(0L, materialExp); + } + + private static long GetRequiredPetExp(short level, Item pet) { + return GetRequiredPetExp(pet); + } + + private static int GetRequiredEvolvePoints(int rarity) { + int safeRarity = Math.Max(1, rarity); + return safeRarity * 10; + } + + private static double GetBattlePetExpScale(Item pet) { + if (pet.Id >= 61100000 || pet.Metadata.Limit.Level >= 60) { + return StrengthenedBattlePetExpScale; + } + + return 1d; + } + + private static int GetRemainingFusionBonuses(GameSession session) { + int characterId = (int) session.CharacterId; + DateOnly today = DateOnly.FromDateTime(DateTime.Now); + + DailyFusionBonusState state = DailyFusionBonusByCharacter.AddOrUpdate(characterId, + _ => new DailyFusionBonusState { Day = today, UsedCount = 0 }, + (_, existing) => { + if (existing.Day != today) { + existing.Day = today; + existing.UsedCount = 0; + } + + return existing; + }); + + return Math.Max(0, Constant.DailyPetEnchantMaxCount - state.UsedCount); + } + + private static void ConsumeFusionBonus(GameSession session) { + int characterId = (int) session.CharacterId; + DateOnly today = DateOnly.FromDateTime(DateTime.Now); + + DailyFusionBonusByCharacter.AddOrUpdate(characterId, + _ => new DailyFusionBonusState { Day = today, UsedCount = 1 }, + (_, existing) => { + if (existing.Day != today) { + existing.Day = today; + existing.UsedCount = 0; + } + + existing.UsedCount = Math.Min(Constant.DailyPetEnchantMaxCount, existing.UsedCount + 1); + return existing; + }); } private static void SummonPet(GameSession session, long petUid) { @@ -174,6 +443,7 @@ private static void SummonPet(GameSession session, long petUid) { session.Field.Broadcast(ProxyObjectPacket.AddPet(fieldPet)); session.Pet = new PetManager(session, fieldPet); session.Pet.Load(); + session.Stats.Refresh(); } } } diff --git a/Maple2.Server.Game/PacketHandlers/SkillBookHandler.cs b/Maple2.Server.Game/PacketHandlers/SkillBookHandler.cs index 0a0772cd0..60df9439d 100644 --- a/Maple2.Server.Game/PacketHandlers/SkillBookHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/SkillBookHandler.cs @@ -52,7 +52,10 @@ private void HandleSave(GameSession session, IByteReader packet) { // Switching Active Tab if (savedSkillTab == 0) { - session.Config.Skill.SaveSkillTab(activeSkillTab, ranksToSave); + if (session.Config.Skill.SaveSkillTab(activeSkillTab, ranksToSave)) { + session.Config.UpdateHotbarSkills(); + session.Config.Skill.UpdatePassiveBuffs(); + } return; } @@ -61,7 +64,10 @@ private void HandleSave(GameSession session, IByteReader packet) { var skillTab = packet.ReadClass(); if (skillTab.Id != savedSkillTab) continue; - session.Config.Skill.SaveSkillTab(activeSkillTab, ranksToSave, skillTab); + if (session.Config.Skill.SaveSkillTab(activeSkillTab, ranksToSave, skillTab)) { + session.Config.UpdateHotbarSkills(); + session.Config.Skill.UpdatePassiveBuffs(); + } return; } } diff --git a/Maple2.Server.Game/PacketHandlers/SkillHandler.cs b/Maple2.Server.Game/PacketHandlers/SkillHandler.cs index 1f08231c8..509bd1756 100644 --- a/Maple2.Server.Game/PacketHandlers/SkillHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/SkillHandler.cs @@ -77,7 +77,8 @@ private void HandleUse(GameSession session, IByteReader packet) { long skillUid = packet.ReadLong(); int serverTick = packet.ReadInt(); int skillId = packet.ReadInt(); - short level = packet.ReadShort(); + short clientLevel = packet.ReadShort(); + short level = session.Config.Skill.ResolveSkillLevel(skillId, clientLevel); if (session.HeldLiftup != null) { if (session.HeldLiftup.SkillId == skillId && session.HeldLiftup.Level == level) { diff --git a/Maple2.Server.Game/PacketHandlers/SurvivalHandler.cs b/Maple2.Server.Game/PacketHandlers/SurvivalHandler.cs index 54a3de6b1..a10646935 100644 --- a/Maple2.Server.Game/PacketHandlers/SurvivalHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/SurvivalHandler.cs @@ -1,32 +1,55 @@ -using Maple2.Model.Enum; +using Maple2.Model.Enum; using Maple2.PacketLib.Tools; using Maple2.Server.Core.Constants; using Maple2.Server.Game.PacketHandlers.Field; using Maple2.Server.Game.Session; +using Serilog; namespace Maple2.Server.Game.PacketHandlers; public class SurvivalHandler : FieldPacketHandler { + private static readonly ILogger SurvivalLogger = Log.Logger.ForContext(); + public override RecvOp OpCode => RecvOp.Survival; private enum Command : byte { + JoinSolo = 0, + WithdrawSolo = 1, Equip = 8, + ClaimRewards = 35, } public override void Handle(GameSession session, IByteReader packet) { - var command = packet.Read(); - + byte rawCommand = packet.ReadByte(); + SurvivalLogger.Information("Survival command received cmd={Command}", rawCommand); + Command command = (Command) rawCommand; switch (command) { case Command.Equip: HandleEquip(session, packet); return; + case Command.ClaimRewards: + HandleClaimRewards(session, packet); + return; + case Command.JoinSolo: + session.Survival.TryActivateGoldPass(); + return; + case Command.WithdrawSolo: + return; + default: + session.Survival.TryActivateGoldPass(); + return; } } private static void HandleEquip(GameSession session, IByteReader packet) { - var slot = packet.Read(); + MedalType slot = packet.Read(); int medalId = packet.ReadInt(); - session.Survival.Equip(slot, medalId); } + + private static void HandleClaimRewards(GameSession session, IByteReader packet) { + if (!session.Survival.TryClaimNextReward()) { + SurvivalLogger.Information("Unhandled Survival claim: no claimable rewards available."); + } + } } diff --git a/Maple2.Server.Game/PacketHandlers/TriggerHandler.cs b/Maple2.Server.Game/PacketHandlers/TriggerHandler.cs index 3b54d3d77..3d9e9ca4d 100644 --- a/Maple2.Server.Game/PacketHandlers/TriggerHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/TriggerHandler.cs @@ -1,4 +1,6 @@ -using Maple2.Model.Enum; +using Maple2.Model.Common; +using Maple2.Model.Enum; +using Maple2.Model.Game; using Maple2.PacketLib.Tools; using Maple2.Server.Core.Constants; using Maple2.Server.Game.PacketHandlers.Field; @@ -6,6 +8,7 @@ using Maple2.Server.Game.Model.Widget; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; namespace Maple2.Server.Game.PacketHandlers; @@ -95,15 +98,53 @@ private void HandleUpdateWidget(GameSession session, IByteReader packet) { } private void HandleLoadScript(GameSession session, IByteReader packet) { - int cubeId = packet.ReadInt(); + int cubeCoordKey = packet.ReadInt(); + Logger.Information("TriggerTool requested script load for cubeCoordKey={CubeCoordKey}", cubeCoordKey); + TryBindEditingSmartComputer(session, cubeCoordKey); + session.Send(TriggerPacket.EditScript(HousingFunctionFurnitureRegistry.GetSmartComputerScript(session))); } private void HandleSaveScript(GameSession session, IByteReader packet) { - int cubeId = packet.ReadInt(); + int cubeCoordKey = packet.ReadInt(); + Logger.Information("TriggerTool requested script save for cubeCoordKey={CubeCoordKey}", cubeCoordKey); + TryBindEditingSmartComputer(session, cubeCoordKey); string xml = packet.ReadString(); + HousingFunctionFurnitureRegistry.TrySaveSmartComputerScript(session, xml, out string message); + session.Send(HomeActionPacket.HostAlarm(message)); } private void HandleDiscardScript(GameSession session, IByteReader packet) { - int cubeId = packet.ReadInt(); + int cubeCoordKey = packet.ReadInt(); + TryBindEditingSmartComputer(session, cubeCoordKey); + session.EditingSmartComputerCubeId = 0; + session.Send(TriggerPacket.ResetScript()); + } + + private static void TryBindEditingSmartComputer(GameSession session, int cubeCoordKey) { + if (session.EditingSmartComputerCubeId != 0) { + return; + } + + Plot? plot = session.Housing.GetFieldPlot(); + if (plot is null) { + return; + } + + Vector3B coord; + try { + coord = Vector3B.ConvertFromInt(cubeCoordKey); + } catch { + return; + } + + if (!plot.Cubes.TryGetValue(coord, out PlotCube? cube)) { + return; + } + + if (!HousingFunctionFurnitureRegistry.IsSmartComputer(cube)) { + return; + } + + session.EditingSmartComputerCubeId = cube.Id; } } diff --git a/Maple2.Server.Game/Packets/PetPacket.cs b/Maple2.Server.Game/Packets/PetPacket.cs index c147e0cec..b3cba1bb8 100644 --- a/Maple2.Server.Game/Packets/PetPacket.cs +++ b/Maple2.Server.Game/Packets/PetPacket.cs @@ -128,12 +128,23 @@ public static ByteWriter Fusion(FieldPet pet) { var pWriter = Packet.Of(SendOp.ResponsePet); pWriter.Write(Command.Fusion); pWriter.WriteInt(pet.OwnerId); - pWriter.WriteLong(pet.Pet.Pet?.Exp ?? 0); + pWriter.WriteLong(GetFusionDisplayExp(pet.Pet.Pet?.Exp ?? 0)); pWriter.WriteLong(pet.Pet.Uid); return pWriter; } + public static ByteWriter Fusion(int ownerId, Item pet) { + var pWriter = Packet.Of(SendOp.ResponsePet); + pWriter.Write(Command.Fusion); + pWriter.WriteInt(ownerId); + pWriter.WriteLong(GetFusionDisplayExp(pet.Pet?.Exp ?? 0)); + pWriter.WriteLong(pet.Uid); + + return pWriter; + } + + public static ByteWriter LevelUp(FieldPet pet) { var pWriter = Packet.Of(SendOp.ResponsePet); pWriter.Write(Command.LevelUp); @@ -143,6 +154,24 @@ public static ByteWriter LevelUp(FieldPet pet) { return pWriter; } + public static ByteWriter LevelUp(int ownerId, Item pet) { + var pWriter = Packet.Of(SendOp.ResponsePet); + pWriter.Write(Command.LevelUp); + pWriter.WriteInt(ownerId); + pWriter.WriteInt(pet.Pet?.Level ?? 1); + pWriter.WriteLong(pet.Uid); + + return pWriter; + } + + + + private static long GetFusionDisplayExp(long exp) { + // The compose UI appears to interpret the fusion progress value at half scale. + // Send doubled display progress here so the immediate fusion bar matches the + // actual persisted pet EXP shown after reloading pet info. + return Math.Max(0L, exp * 2L); + } public static ByteWriter FusionCount(int count) { var pWriter = Packet.Of(SendOp.ResponsePet); diff --git a/Maple2.Server.Game/Packets/SkillDamagePacket.cs b/Maple2.Server.Game/Packets/SkillDamagePacket.cs index e7f79fea7..fdef75d66 100644 --- a/Maple2.Server.Game/Packets/SkillDamagePacket.cs +++ b/Maple2.Server.Game/Packets/SkillDamagePacket.cs @@ -20,7 +20,19 @@ private enum Command : byte { Unknown7 = 7, Unknown8 = 8, } - + private static void WriteVector3SSafe(ByteWriter w, Vector3 v) { + if (float.IsNaN(v.X) || float.IsNaN(v.Y) || float.IsNaN(v.Z) || + float.IsInfinity(v.X) || float.IsInfinity(v.Y) || float.IsInfinity(v.Z)) { + v = Vector3.Zero; + } + + // 注意:4200 没问题,但 NaN/Inf 必须先处理,否则 Clamp 也救不了 + v.X = Math.Clamp(v.X, short.MinValue, short.MaxValue); + v.Y = Math.Clamp(v.Y, short.MinValue, short.MaxValue); + v.Z = Math.Clamp(v.Z, short.MinValue, short.MaxValue); + + w.Write(v); + } public static ByteWriter Target(SkillRecord record, ICollection targets) { var pWriter = Packet.Of(SendOp.SkillDamage); pWriter.Write(Command.Target); @@ -30,7 +42,7 @@ public static ByteWriter Target(SkillRecord record, ICollection ta pWriter.WriteShort(record.Level); pWriter.WriteByte(record.MotionPoint); pWriter.WriteByte(record.AttackPoint); - pWriter.Write(record.Position); // Impact + WriteVector3SSafe(pWriter, record.Position); // Impact pWriter.Write(record.Direction); // Impact pWriter.WriteBool(true); // SkillId:10600211 only pWriter.WriteInt(record.ServerTick); @@ -53,8 +65,8 @@ public static ByteWriter Damage(DamageRecord record) { pWriter.WriteShort(record.Level); pWriter.WriteByte(record.MotionPoint); pWriter.WriteByte(record.AttackPoint); - pWriter.Write(record.Position); // Impact - pWriter.Write(record.Direction); + WriteVector3SSafe(pWriter, record.Position); // Impact + WriteVector3SSafe(pWriter, record.Direction); pWriter.WriteByte((byte) record.Targets.Count); foreach (DamageRecordTarget target in record.Targets.Values) { diff --git a/Maple2.Server.Game/Packets/SurvivalPacket.cs b/Maple2.Server.Game/Packets/SurvivalPacket.cs index 33f127828..e56575fc7 100644 --- a/Maple2.Server.Game/Packets/SurvivalPacket.cs +++ b/Maple2.Server.Game/Packets/SurvivalPacket.cs @@ -1,4 +1,4 @@ -using Maple2.Model.Enum; +using Maple2.Model.Enum; using Maple2.Model.Game; using Maple2.PacketLib.Tools; using Maple2.Server.Core.Constants; @@ -25,34 +25,33 @@ private enum Command : byte { ClaimRewards = 35, } - public static ByteWriter UpdateStats(Account account, long expGained = 0) { + public static ByteWriter UpdateStats(Account account, long displayExp, long expGained = 0) { var pWriter = Packet.Of(SendOp.Survival); - pWriter.Write(Command.UpdateStats); + pWriter.WriteByte((byte)Command.UpdateStats); pWriter.WriteLong(account.Id); - pWriter.WriteInt(); + pWriter.WriteInt(0); pWriter.WriteBool(account.ActiveGoldPass); - pWriter.WriteLong(account.SurvivalExp); + pWriter.WriteLong(displayExp); pWriter.WriteInt(account.SurvivalLevel); pWriter.WriteInt(account.SurvivalSilverLevelRewardClaimed); pWriter.WriteInt(account.SurvivalGoldLevelRewardClaimed); pWriter.WriteLong(expGained); - return pWriter; } public static ByteWriter LoadMedals(IDictionary> inventory, IDictionary equips) { var pWriter = Packet.Of(SendOp.Survival); - pWriter.Write(Command.LoadMedals); - pWriter.WriteByte((byte) inventory.Keys.Count); - foreach ((MedalType type, Dictionary medals) in inventory) { - pWriter.WriteInt(equips[type].Id); - pWriter.WriteInt(medals.Count); - foreach (Medal medal in medals.Values) { + pWriter.WriteByte((byte)Command.LoadMedals); + pWriter.WriteByte((byte)inventory.Keys.Count); + foreach (KeyValuePair> entry in inventory) { + Medal equipped = equips.ContainsKey(entry.Key) ? equips[entry.Key] : new Medal(0, entry.Key); + pWriter.WriteInt(equipped.Id); + pWriter.WriteInt(entry.Value.Count); + foreach (Medal medal in entry.Value.Values) { pWriter.WriteInt(medal.Id); - pWriter.WriteLong(long.MaxValue); + pWriter.WriteLong(medal.ExpiryTime <= 0 ? long.MaxValue : medal.ExpiryTime); } } - return pWriter; } } diff --git a/Maple2.Server.Game/Packets/TriggerPacket.cs b/Maple2.Server.Game/Packets/TriggerPacket.cs index 16b716dc2..99cc52737 100644 --- a/Maple2.Server.Game/Packets/TriggerPacket.cs +++ b/Maple2.Server.Game/Packets/TriggerPacket.cs @@ -147,7 +147,7 @@ public static ByteWriter DuelHpBar(bool set, int objectId = 0, int durationTick public static ByteWriter ResetScript(bool error = false) { var pWriter = Packet.Of(SendOp.Trigger); pWriter.Write(Command.ResetScript); - pWriter.WriteBool(error); // no error=>s_user_trigger_msg_rollback + pWriter.WriteInt(error ? 1 : 0); // no error=>s_user_trigger_msg_rollback return pWriter; } diff --git a/Maple2.Server.Game/Program.cs b/Maple2.Server.Game/Program.cs index 66c002a9b..78ca3a023 100644 --- a/Maple2.Server.Game/Program.cs +++ b/Maple2.Server.Game/Program.cs @@ -106,6 +106,9 @@ )); builder.Services.AddHostedService(provider => provider.GetService()!); +// Periodically persist online player state so restarts don't lose progress. +builder.Services.AddHostedService(); + builder.Services.AddGrpcHealthChecks(); builder.Services.Configure(options => { options.Delay = TimeSpan.Zero; diff --git a/Maple2.Server.Game/Service/AutoSaveService.cs b/Maple2.Server.Game/Service/AutoSaveService.cs new file mode 100644 index 000000000..201f31077 --- /dev/null +++ b/Maple2.Server.Game/Service/AutoSaveService.cs @@ -0,0 +1,69 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Maple2.Server.Game; +using Maple2.Server.Game.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Maple2.Server.Game.Service; + +/// +/// Periodically saves online player state so progress isn't lost if the server restarts. +/// +/// Why here (Server.Game): +/// - The database/storage layer doesn't know which players are online. +/// - GameServer owns the live sessions, so it can safely iterate and call SessionSave(). +/// +public sealed class AutoSaveService : BackgroundService { + private static readonly TimeSpan SaveInterval = TimeSpan.FromSeconds(60); + + private readonly GameServer gameServer; + private readonly ILogger logger; + + public AutoSaveService(GameServer gameServer, ILogger logger) { + this.gameServer = gameServer; + this.logger = logger; + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { + // Small startup delay so we don't compete with initial login/boot work. + try { + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); + } catch (OperationCanceledException) { + return; + } + + while (!stoppingToken.IsCancellationRequested) { + try { + int saved = 0; + + // Snapshot current sessions to avoid issues if the collection changes mid-iteration. + GameSession[] sessions = gameServer.GetSessions().ToArray(); + foreach (GameSession session in sessions) { + if (stoppingToken.IsCancellationRequested) break; + if (session.Player == null) continue; + + // SessionSave() is internally locked and already checks for null Player. + session.SessionSave(); + saved++; + } + + if (saved > 0) { + logger.LogInformation("[AutoSave] Saved {Count} online session(s).", saved); + } + } catch (OperationCanceledException) { + // Normal shutdown. + } catch (Exception ex) { + logger.LogError(ex, "[AutoSave] Unexpected error while saving sessions."); + } + + try { + await Task.Delay(SaveInterval, stoppingToken); + } catch (OperationCanceledException) { + break; + } + } + } +} diff --git a/Maple2.Server.Game/Service/ChannelService.Guild.cs b/Maple2.Server.Game/Service/ChannelService.Guild.cs index 5ddf9f7e2..1cc2fb9c4 100644 --- a/Maple2.Server.Game/Service/ChannelService.Guild.cs +++ b/Maple2.Server.Game/Service/ChannelService.Guild.cs @@ -4,6 +4,8 @@ using Maple2.Server.Channel.Service; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using WorldGuildInfoRequest = Maple2.Server.World.Service.GuildInfoRequest; +using WorldGuildInfoResponse = Maple2.Server.World.Service.GuildInfoResponse; namespace Maple2.Server.Game.Service; @@ -82,13 +84,25 @@ private GuildResponse AddGuildMember(long guildId, IEnumerable receiverIds continue; } - // Intentionally create a separate GuildMember instance for each session. - session.Guild.AddMember(add.RequestorName, new GuildMember { + var member = new GuildMember { GuildId = guildId, Info = info.Clone(), Rank = (byte) add.Rank, JoinTime = add.JoinTime, - }); + }; + + if (session.CharacterId == add.CharacterId && session.Guild.Guild == null) { + WorldGuildInfoResponse response = session.World.GuildInfo(new WorldGuildInfoRequest { + GuildId = guildId, + }); + + if (response.Guild != null) { + session.Guild.SetGuild(response.Guild); + session.Guild.Load(); + } + } + + session.Guild.AddMember(add.RequestorName, member); } return new GuildResponse(); diff --git a/Maple2.Server.Game/Service/ChannelService.UpdateFieldPlot.cs b/Maple2.Server.Game/Service/ChannelService.UpdateFieldPlot.cs index 8b6c56c65..9215b4e44 100644 --- a/Maple2.Server.Game/Service/ChannelService.UpdateFieldPlot.cs +++ b/Maple2.Server.Game/Service/ChannelService.UpdateFieldPlot.cs @@ -4,6 +4,7 @@ using Maple2.Model.Game; using Maple2.Server.Game.Manager.Field; using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Util; using Maple2.Server.Game.Session; namespace Maple2.Server.Game.Service; @@ -100,6 +101,7 @@ private void HandleUpdateBlock(int mapId, int plotNumber, FieldPlotRequest.Types if (isReplace && plot.Cubes.Remove(plotCube.Position, out PlotCube? cube)) { if (cube.Interact is not null) { + HousingFunctionFurnitureRegistry.Cleanup(fieldManager, cube); fieldManager.RemoveFieldFunctionInteract(cube.Interact.Id); } } @@ -107,9 +109,7 @@ private void HandleUpdateBlock(int mapId, int plotNumber, FieldPlotRequest.Types plotCube.PlotId = plot.Number; plot.Cubes.Add(plotCube.Position, plotCube); - if (plotCube.Interact is not null) { - fieldManager.AddFieldFunctionInteract(plotCube); - } + fieldManager.AddFieldFunctionInteract(plotCube); if (isReplace) { fieldManager.Broadcast(CubePacket.ReplaceCube(fieldManager.FieldActor.ObjectId, plotCube)); } else { @@ -127,6 +127,7 @@ private void HandleUpdateBlock(int mapId, int plotNumber, FieldPlotRequest.Types return; } if (cubeToRemove.Interact is not null) { + HousingFunctionFurnitureRegistry.Cleanup(fieldManager, cubeToRemove); fieldManager.RemoveFieldFunctionInteract(cubeToRemove.Interact.Id); } fieldManager.Broadcast(CubePacket.RemoveCube(fieldManager.FieldActor.ObjectId, positionToRemove)); diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index d4644d6a6..6d1b036c0 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -110,6 +110,9 @@ public sealed partial class GameSession : Core.Network.Session { public RideManager Ride { get; set; } = null!; public MentoringManager Mentoring { get; set; } = null!; + // Runtime-only: currently edited smart-computer furniture cube in housing. + public long EditingSmartComputerCubeId { get; set; } + public GameSession(TcpClient tcpClient, GameServer server, IComponentContext context) : base(tcpClient) { this.server = server; State = SessionState.ChangeMap; @@ -283,7 +286,7 @@ public bool EnterServer(long accountId, Guid machineId, MigrateInResponse migrat Logger.Warning(ex, "Failed to load cache player config"); } - Send(SurvivalPacket.UpdateStats(player.Account)); + Survival.Load(); Send(TimeSyncPacket.Reset(DateTimeOffset.UtcNow)); Send(TimeSyncPacket.Set(DateTimeOffset.UtcNow)); diff --git a/Maple2.Server.Game/Trigger/TriggerContext.Field.cs b/Maple2.Server.Game/Trigger/TriggerContext.Field.cs index c925fbc1a..4855533cb 100644 --- a/Maple2.Server.Game/Trigger/TriggerContext.Field.cs +++ b/Maple2.Server.Game/Trigger/TriggerContext.Field.cs @@ -144,9 +144,9 @@ public void SetActor(int triggerId, bool visible, string initialSequence, bool a public void SetAgent(int[] triggerIds, bool visible) { WarnLog("[SetAgent] triggerIds:{Ids}, visible:{Visible}", string.Join(", ", triggerIds), visible); foreach (int triggerId in triggerIds) { - if (!Objects.Agents.TryGetValue(triggerId, out TriggerObjectAgent? agent)) { - continue; - } + // Some data packs are missing Ms2TriggerAgent entries in DB import. + // Create a lightweight placeholder so the client can still receive updates. + TriggerObjectAgent agent = Objects.GetOrAddAgent(triggerId); agent.Visible = visible; Broadcast(TriggerPacket.Update(agent)); @@ -275,10 +275,9 @@ public void SetRandomMesh(int[] triggerIds, bool visible, int startDelay, int in private void UpdateMesh(ArraySegment triggerIds, bool visible, int delay, int interval, int fade = 0) { int intervalTotal = 0; foreach (int triggerId in triggerIds) { - if (!Objects.Meshes.TryGetValue(triggerId, out TriggerObjectMesh? mesh)) { - logger.Warning("Invalid mesh: {Id}", triggerId); - continue; - } + // Some data packs are missing Ms2TriggerMesh entries in DB import. + // Create a lightweight placeholder so the client can still receive updates. + TriggerObjectMesh mesh = Objects.GetOrAddMesh(triggerId); if (mesh.Visible == visible) { continue; } diff --git a/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs b/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs index 3faacf127..67c8a75bc 100644 --- a/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs +++ b/Maple2.Server.Game/Trigger/TriggerContext.Npc.cs @@ -211,13 +211,27 @@ public bool MonsterDead(int[] spawnIds, bool autoTarget) { DebugLog("[MonsterDead] spawnIds:{SpawnIds}, arg2:{Arg2}", string.Join(", ", spawnIds), autoTarget); IEnumerable matchingMobs = Field.Mobs.Values.Where(x => spawnIds.Contains(x.SpawnPointId)); + // If no mobs currently exist for these spawnIds, we may still want to treat them as dead + // (e.g. boss already died and was despawned). We use Field.IsSpawnPointDead to track that. + // However, if a spawnId has never existed and is not marked dead, it should NOT be treated as dead + // (prevents instant fail when an NPC failed to spawn). + + foreach (FieldNpc mob in matchingMobs) { if (!mob.IsDead) { return false; } } - // Either no mobs were found or they are all dead + // If we found at least one matching mob, reaching here means all of them are dead. + // If we found none, then only consider the spawnIds dead if they are marked as dead by the field. + if (!matchingMobs.Any()) { + foreach (int spawnId in spawnIds) { + if (!Field.IsSpawnPointDead(spawnId)) { + return false; + } + } + } return true; } diff --git a/Maple2.Server.Game/Util/DamageCalculator.cs b/Maple2.Server.Game/Util/DamageCalculator.cs index 09fafc089..977fe516c 100644 --- a/Maple2.Server.Game/Util/DamageCalculator.cs +++ b/Maple2.Server.Game/Util/DamageCalculator.cs @@ -1,4 +1,4 @@ -using Maple2.Model.Enum; +using Maple2.Model.Enum; using Maple2.Model.Metadata; using Maple2.Server.Core.Formulas; using Maple2.Server.Game.Model; @@ -27,6 +27,10 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D var damageType = DamageType.Normal; (double minBonusAtkDamage, double maxBonusAtkDamage) = caster.Stats.GetBonusAttack(target.Buffs.GetResistance(BasicAttribute.BonusAtk), target.Buffs.GetResistance(BasicAttribute.MaxWeaponAtk)); + if (caster is FieldPet { OwnerId: > 0 } ownedPet) { + (minBonusAtkDamage, maxBonusAtkDamage) = GetOwnedPetAttackRange(ownedPet); + } + double attackDamage = minBonusAtkDamage + (maxBonusAtkDamage - minBonusAtkDamage) * double.Lerp(Random.Shared.NextDouble(), Random.Shared.NextDouble(), Random.Shared.NextDouble()); // change the NPCNormalDamage to be changed depending on target? @@ -90,13 +94,16 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D double damageMultiplier = damageBonus * (1 + invokeRate) * (property.Rate + invokeValue); - double defensePierce = 1 - Math.Min(0.3, (1 / (1 + target.Buffs.GetResistance(BasicAttribute.Piercing)) * (caster.Stats.Values[BasicAttribute.Piercing].Multiplier() - 1))); - damageMultiplier *= 1 / (Math.Max(target.Stats.Values[BasicAttribute.Defense].Total, 1) * defensePierce); + double defensePierceRate = caster.Stats.Values[BasicAttribute.Piercing].Multiplier(); + defensePierceRate *= 1 / (1 + target.Buffs.GetResistance(BasicAttribute.Piercing)); + double defenseFactor = 1 - Math.Min(0.3, Math.Max(0d, defensePierceRate)); + damageMultiplier *= 1 / (Math.Max(target.Stats.Values[BasicAttribute.Defense].Total, 1) * defenseFactor); // Check resistances double attackTypeAmount = 0; double resistance = 0; double finalDamage = 0; + FieldPet? ownedPetCaster = caster as FieldPet; switch (property.AttackType) { case AttackType.Physical: resistance = Damage.CalculateResistance(target.Stats.Values[BasicAttribute.PhysicalRes].Total, caster.Stats.Values[SpecialAttribute.PhysicalPiercing].Multiplier()); @@ -113,6 +120,9 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D BasicAttribute attackTypeAttribute = caster.Stats.Values[BasicAttribute.PhysicalAtk].Total >= caster.Stats.Values[BasicAttribute.MagicalAtk].Total ? BasicAttribute.PhysicalAtk : BasicAttribute.MagicalAtk; + BasicAttribute resistanceAttribute = attackTypeAttribute == BasicAttribute.PhysicalAtk + ? BasicAttribute.PhysicalRes + : BasicAttribute.MagicalRes; SpecialAttribute piercingAttribute = attackTypeAttribute == BasicAttribute.PhysicalAtk ? SpecialAttribute.PhysicalPiercing : SpecialAttribute.MagicalPiercing; @@ -120,12 +130,17 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D ? SpecialAttribute.OffensivePhysicalDamage : SpecialAttribute.OffensiveMagicalDamage; attackTypeAmount = Math.Max(caster.Stats.Values[BasicAttribute.PhysicalAtk].Total, caster.Stats.Values[BasicAttribute.MagicalAtk].Total) * 0.5f; - resistance = Damage.CalculateResistance(target.Stats.Values[attackTypeAttribute].Total, caster.Stats.Values[piercingAttribute].Multiplier()); + resistance = Damage.CalculateResistance(target.Stats.Values[resistanceAttribute].Total, caster.Stats.Values[piercingAttribute].Multiplier()); finalDamage = caster.Stats.Values[finalDamageAttribute].Multiplier(); break; } - damageMultiplier *= attackTypeAmount * resistance * (finalDamage == 0 ? 1 : finalDamage); + if (ownedPetCaster is { OwnerId: > 0 }) { + attackTypeAmount = Math.Max(attackTypeAmount, GetOwnedPetAttackFactor(ownedPetCaster)); + } + + double finalDamageFactor = 1 + Math.Max(0d, finalDamage); + damageMultiplier *= attackTypeAmount * resistance * finalDamageFactor; attackDamage *= damageMultiplier * Constant.AttackDamageFactor + property.Value; // Apply any shields @@ -143,4 +158,59 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D return (damageType, (long) Math.Max(1, attackDamage)); } -} + + private static double GetOwnedPetAttackFactor(FieldPet pet) { + long physicalAtk = pet.Stats.Values[BasicAttribute.PhysicalAtk].Total; + long magicalAtk = pet.Stats.Values[BasicAttribute.MagicalAtk].Total; + long minWeaponAtk = pet.Stats.Values[BasicAttribute.MinWeaponAtk].Total; + long maxWeaponAtk = pet.Stats.Values[BasicAttribute.MaxWeaponAtk].Total; + double petBonusAtk = GetTotalPetBonusAttack(pet); + + double directAtk = Math.Max(physicalAtk, magicalAtk); + double weaponAtk = Math.Max(minWeaponAtk, maxWeaponAtk); + double bonusDrivenAtk = petBonusAtk > 0 ? petBonusAtk * 4.96d : 0d; + + double factor = Math.Max(directAtk, weaponAtk); + factor = Math.Max(factor, bonusDrivenAtk); + + if (petBonusAtk > 0) { + factor += petBonusAtk * 1.50d; + } + + return Math.Max(1d, factor); + } + + private static (double Min, double Max) GetOwnedPetAttackRange(FieldPet pet) { + long minWeaponAtk = pet.Stats.Values[BasicAttribute.MinWeaponAtk].Total; + long maxWeaponAtk = pet.Stats.Values[BasicAttribute.MaxWeaponAtk].Total; + long physicalAtk = pet.Stats.Values[BasicAttribute.PhysicalAtk].Total; + long magicalAtk = pet.Stats.Values[BasicAttribute.MagicalAtk].Total; + double petBonusAtk = GetTotalPetBonusAttack(pet); + + double attackBase = Math.Max(physicalAtk, magicalAtk); + if (attackBase <= 0) { + attackBase = Math.Max(minWeaponAtk, maxWeaponAtk); + } + + double bonusDrivenAtk = petBonusAtk > 0 ? petBonusAtk * 4.96d : 0d; + attackBase = Math.Max(attackBase, bonusDrivenAtk); + + if (attackBase <= 0) { + attackBase = 100d; + } + + double min = Math.Max(1d, minWeaponAtk > 0 ? Math.Max(minWeaponAtk, attackBase * 0.65d) : attackBase * 0.65d); + double max = Math.Max(min + 1d, maxWeaponAtk > 0 ? Math.Max(maxWeaponAtk, attackBase * 0.95d) : attackBase * 0.95d); + return (min, max); + } + + private static double GetTotalPetBonusAttack(FieldPet pet) { + double petBonusAtk = pet.Stats.Values[BasicAttribute.PetBonusAtk].Total + pet.Stats.Values[BasicAttribute.BonusAtk].Total; + + if (pet.Field.Players.TryGetValue(pet.OwnerId, out FieldPlayer? owner)) { + petBonusAtk += owner.Stats.Values[BasicAttribute.PetBonusAtk].Total; + } + + return Math.Max(0d, petBonusAtk); + } +} \ No newline at end of file diff --git a/Maple2.Server.Game/Util/HousingFunctionFurnitureRegistry.cs b/Maple2.Server.Game/Util/HousingFunctionFurnitureRegistry.cs new file mode 100644 index 000000000..5e0bbfaf3 --- /dev/null +++ b/Maple2.Server.Game/Util/HousingFunctionFurnitureRegistry.cs @@ -0,0 +1,914 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Numerics; +using System.Text; +using Maple2.Database.Storage; +using Maple2.Model.Common; +using Maple2.Model.Enum; +using Maple2.Model.Game; +using Maple2.Model.Metadata; +using Maple2.Server.Core.Constants; +using Maple2.Server.Game.Manager.Field; +using Maple2.Server.Game.Model; +using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.Util; + +internal static class HousingFunctionFurnitureRegistry { + private static readonly HashSet SmartComputerItemIds = new() { + 50400005, + 50400178, + }; + + internal enum FurnitureBehavior { + None, + Trap, + InstallNpc, + FunctionUi, + CharmBuff, + } + + internal sealed record FurnitureDefinition( + int ItemId, + FurnitureBehavior Behavior, + int BuffId = 0, + short BuffLevel = 1, + int NpcId = 0, + float TriggerRadius = 150f, + int AutoStateChangeTime = 2500, + string DebugName = "" + ); + + private static readonly IReadOnlyDictionary Definitions = new Dictionary { + [50300001] = new(50300001, FurnitureBehavior.Trap, BuffId: 40011011, TriggerRadius: 180f, DebugName: "ca_skillobj_trap_A01_"), + [50300002] = new(50300002, FurnitureBehavior.Trap, BuffId: 40501014, TriggerRadius: 180f, DebugName: "ho_skillobj_trap_A01_"), + [50300003] = new(50300003, FurnitureBehavior.Trap, BuffId: 50000087, TriggerRadius: 180f, DebugName: "lu_skillobj_trap_A01_"), + [50300004] = new(50300004, FurnitureBehavior.Trap, BuffId: 40011011, TriggerRadius: 180f, DebugName: "co_skillobj_Confusion_A01_"), + [50300005] = new(50300005, FurnitureBehavior.Trap, BuffId: 40501014, TriggerRadius: 180f, DebugName: "co_skillobj_Frozen_A01_"), + [50300006] = new(50300006, FurnitureBehavior.Trap, BuffId: 50000087, TriggerRadius: 180f, DebugName: "co_skillobj_Blind_A01_"), + [50300007] = new(50300007, FurnitureBehavior.Trap, BuffId: 10000031, BuffLevel: 13, TriggerRadius: 220f, AutoStateChangeTime: 1800, DebugName: "co_skillobj_electricfan_A01_"), + [50300008] = new(50300008, FurnitureBehavior.Trap, BuffId: 10000031, BuffLevel: 13, TriggerRadius: 220f, AutoStateChangeTime: 1800, DebugName: "co_skillobj_octopus_A01_"), + [50300009] = new(50300009, FurnitureBehavior.Trap, BuffId: 10000031, BuffLevel: 13, TriggerRadius: 220f, AutoStateChangeTime: 1800, DebugName: "co_skillobj_octopus_B01_"), + [50300010] = new(50300010, FurnitureBehavior.Trap, BuffId: 10000031, BuffLevel: 13, TriggerRadius: 220f, AutoStateChangeTime: 1800, DebugName: "co_skillobj_crepper_B03"), + [50300014] = new(50300014, FurnitureBehavior.InstallNpc, NpcId: 52000005, AutoStateChangeTime: 1500, DebugName: "51000001_DamageMeter_puppet_A01_"), + [50300015] = new(50300015, FurnitureBehavior.InstallNpc, NpcId: 52000006, AutoStateChangeTime: 1500, DebugName: "52000006_DamageMeter_snowman_A01_"), + [50400005] = new(50400005, FurnitureBehavior.FunctionUi, AutoStateChangeTime: 1000, DebugName: "co_functobj_pc_B01_"), + [50400178] = new(50400178, FurnitureBehavior.FunctionUi, AutoStateChangeTime: 1000, DebugName: "co_functobj_editor_A01_"), + }; + + // Exact charm/trophy item -> buff mappings resolved from item descriptions and client additional-effect strings. + // Items that only say “可通过动作键启动” still fall back by trophy level until their original behavior is traced. + private static readonly IReadOnlyDictionary CharmItemBuffOverrides = new Dictionary { + [50710002] = 90000143, + [50710006] = 90000140, + [50710007] = 90000145, + [50710010] = 79010010, + [50710011] = 79010011, + [50710012] = 79070012, + [50710018] = 79070013, + [50750001] = 90000134, + [50750002] = 90000139, + [50750003] = 90000140, + [50750004] = 90000141, + [50750005] = 90000142, + [50760000] = 90000215, + [50760003] = 90000318, + [50770000] = 90000240, + [50770001] = 90000241, + [50770002] = 90000242, + [50770003] = 90000243, + [50770004] = 90000252, + [50770007] = 90000277, + [50770008] = 90000278, + [50770009] = 79070009, + [50770010] = 90000740, + }; + + // Fallback pools intentionally use only 60-minute trophy-style additional effects. + // This avoids the old issue where charm furniture incorrectly granted 15-minute food/event buffs. + private static readonly int[] LowTierCharmBuffs = new[] { + 90000138, 90000143, 90000145, 90000177, 90000240, 90000241, + }; + + private static readonly int[] MidTierCharmBuffs = new[] { + 79070009, 79070012, 79070013, 90000146, 90000252, 90000318, + }; + + private static readonly int[] HighTierCharmBuffs = new[] { + 79010010, 79010011, 90000168, 90000169, 90000170, 90000740, + }; + + private static readonly int[] TopTierCharmBuffs = new[] { + 79010010, 79010011, 90000740, + }; + + private static readonly int[] CharmBuffIds = CharmItemBuffOverrides.Values + .Concat(LowTierCharmBuffs) + .Concat(MidTierCharmBuffs) + .Concat(HighTierCharmBuffs) + .Concat(TopTierCharmBuffs) + .Distinct() + .ToArray(); + + private static readonly HashSet CharmBuffIdSet = new(CharmBuffIds); + + private static readonly ConcurrentDictionary TrapCooldowns = new(); + + public static bool TryGetDefinition(ItemMetadata itemMetadata, out FurnitureDefinition definition) { + if (Definitions.TryGetValue(itemMetadata.Id, out FurnitureDefinition? found)) { + definition = found; + return true; + } + + if (itemMetadata.Housing is { TrophyId: > 0 } housing) { + definition = new FurnitureDefinition( + itemMetadata.Id, + FurnitureBehavior.CharmBuff, + BuffId: ResolveCharmBuffId(itemMetadata.Id, housing.TrophyId, housing.TrophyLevel), + DebugName: $"housing_trophy_{housing.TrophyId}_{housing.TrophyLevel}"); + return true; + } + + definition = default!; + return false; + } + + public static FunctionCubeMetadata? Resolve(ItemMetadata itemMetadata, FunctionCubeMetadataStorage storage) { + ItemMetadataInstall? install = itemMetadata.Install; + if (install is null) { + return null; + } + + if (TryGetDefinition(itemMetadata, out FurnitureDefinition definition)) { + return CreateSyntheticMetadata(install, definition); + } + + return storage.TryGet(install.ObjectCubeId, out FunctionCubeMetadata? existing) ? existing : null; + } + + public static bool EnsureInteract(PlotCube cube, FunctionCubeMetadataStorage storage) { + FunctionCubeMetadata? metadata = Resolve(cube.Metadata, storage); + if (metadata is null) { + return false; + } + + if (cube.Interact is null || cube.Interact.Metadata.ControlType != metadata.ControlType || cube.Interact.Metadata.Id != metadata.Id) { + CubePortalSettings? portalSettings = cube.Interact?.PortalSettings; + CubeNoticeSettings? noticeSettings = cube.Interact?.NoticeSettings; + cube.Interact = new InteractCube(cube.Position, metadata) { + PortalSettings = portalSettings ?? cube.Interact?.PortalSettings, + NoticeSettings = noticeSettings ?? cube.Interact?.NoticeSettings, + }; + } + + return true; + } + + public static void Materialize(FieldManager field, PlotCube cube) { + if (cube.Interact is null || !TryGetDefinition(cube.Metadata, out FurnitureDefinition definition)) { + return; + } + + switch (definition.Behavior) { + case FurnitureBehavior.Trap: + EnsureTrapVisual(field, cube, definition); + break; + case FurnitureBehavior.InstallNpc: + EnsureNpcSpawned(field, cube, definition); + break; + case FurnitureBehavior.FunctionUi: + EnsureConfigurableNotice(cube); + EnsureSmartComputerTemplate(cube); + TryInstallSavedComputerTrigger(field, cube); + break; + case FurnitureBehavior.CharmBuff: + EnsureConfigurableNotice(cube); + break; + } + } + + public static void Cleanup(FieldManager field, PlotCube cube) { + if (cube.Interact is null) { + return; + } + + if (cube.Interact.SpawnedNpcObjectId != 0) { + field.RemoveNpc(cube.Interact.SpawnedNpcObjectId); + cube.Interact.SpawnedNpcObjectId = 0; + } + + string visualEntityId = GetTrapVisualEntityId(cube); + if (field.TryGetInteract(visualEntityId, out FieldInteract? visualInteract)) { + field.RemoveInteract(visualInteract.Object); + } + } + + public static bool TryHandleUse(GameSession session, PlotCube cube, FieldFunctionInteract fieldInteract) { + if (session.Field is null || cube.Interact is null || !TryGetDefinition(cube.Metadata, out FurnitureDefinition definition)) { + return false; + } + + switch (definition.Behavior) { + case FurnitureBehavior.CharmBuff: + return HandleCharmBuff(session, cube, fieldInteract, definition); + case FurnitureBehavior.FunctionUi: + return HandleFunctionUi(session, cube, fieldInteract); + default: + return false; + } + } + + public static bool TryTriggerTrap(GameSession session, PlotCube cube) { + if (session.Field is null || !TryGetDefinition(cube.Metadata, out FurnitureDefinition definition) || definition.Behavior is not FurnitureBehavior.Trap) { + return false; + } + + Vector3 cubeWorldPosition = cube.Position; + Vector2 playerPosition = new(session.Player.Position.X, session.Player.Position.Y); + Vector2 cubePosition = new(cubeWorldPosition.X, cubeWorldPosition.Y); + float distance = Vector2.Distance(playerPosition, cubePosition); + float zDistance = MathF.Abs(session.Player.Position.Z - cubeWorldPosition.Z); + if (distance > definition.TriggerRadius || zDistance > 180f) { + return false; + } + + string cooldownKey = $"{session.CharacterId}:{cube.Id}"; + long now = session.Field.FieldTick; + if (TrapCooldowns.TryGetValue(cooldownKey, out long nextAllowedTick) && now < nextAllowedTick) { + return false; + } + TrapCooldowns[cooldownKey] = now + Math.Max(definition.AutoStateChangeTime, 2500); + + bool hasRuntime = EnsureRuntimeInteract(session.Field, cube, out _); + if (hasRuntime && cube.Interact is not null) { + cube.Interact.State = InteractCubeState.InUse; + cube.Interact.InteractingCharacterId = session.CharacterId; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + session.Field.Broadcast(FunctionCubePacket.UseFurniture(session.CharacterId, cube.Interact)); + } + + if (definition.BuffId > 0) { + session.Player.Buffs.AddBuff(session.Player, session.Player, definition.BuffId, definition.BuffLevel, session.Field.FieldTick, notifyField: true); + } + + return true; + } + + internal static bool IsSmartComputer(PlotCube cube) => SmartComputerItemIds.Contains(cube.ItemId); + + internal static bool IsHousingCharmBuff(int buffId) => CharmBuffIdSet.Contains(buffId); + + internal static bool OpenSmartComputerEditor(GameSession session, PlotCube cube) { + if (session.Field is null || !IsSmartComputer(cube)) { + return false; + } + + EnsureInteract(cube, session.FunctionCubeMetadata); + EnsureConfigurableNotice(cube); + EnsureSmartComputerTemplate(cube); + + if (cube.Interact?.NoticeSettings is null) { + return false; + } + + cube.Interact.State = InteractCubeState.InUse; + cube.Interact.InteractingCharacterId = session.CharacterId; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + session.Field.Broadcast(FunctionCubePacket.UseFurniture(session.CharacterId, cube.Interact)); + + session.EditingSmartComputerCubeId = cube.Id; + cube.Interact.NoticeSettings.Notice = GetSmartComputerScript(session); + cube.Interact.NoticeSettings.Distance = 1; + + session.Send(HomeActionPacket.SendCubeNoticeSettings(cube, editing: true)); + return true; + } + + internal static string ApplyComputerScript(GameSession session, PlotCube cube) { + if (!IsSmartComputer(cube)) { + return "已保存。"; + } + + if (session.Field is not HomeFieldManager homeField || homeField.OwnerId != session.AccountId) { + return "只有房主可以保存智能电脑配置。"; + } + + if (cube.Interact?.NoticeSettings is null) { + return "智能电脑未初始化配置区。"; + } + + Plot? plot = session.Housing.GetFieldPlot(); + if (plot is null || session.Field is null) { + return "当前不在有效的房屋场景中。"; + } + + string script = cube.Interact.NoticeSettings.Notice ?? string.Empty; + if (string.IsNullOrWhiteSpace(script)) { + return "智能电脑已清空配置。"; + } + + int changedStates = 0; + int materialized = 0; + int portalUpdates = 0; + int noticeUpdates = 0; + int variables = 0; + int errors = 0; + + foreach (string rawLine in script.Replace("\r", string.Empty).Split("\n")) { + string line = rawLine.Trim(); + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#') || line.StartsWith("//")) { + continue; + } + + List tokens = Tokenize(line); + if (tokens.Count == 0) { + continue; + } + + string command = tokens[0].ToLowerInvariant(); + switch (command) { + case "toggle": + if (TryParseCoord(tokens, 1, out Vector3B toggleCoord) && TryGetCube(plot, toggleCoord, out PlotCube? toggleCube) && + TryToggleCube(session, toggleCube)) { + changedStates++; + } else { + errors++; + } + break; + case "state": + if (TryParseCoord(tokens, 1, out Vector3B stateCoord) && + TryGetCube(plot, stateCoord, out PlotCube? stateCube) && + tokens.Count >= 5 && + TryParseCubeState(tokens[4], out InteractCubeState state) && + TrySetCubeState(session, stateCube, state)) { + changedStates++; + } else { + errors++; + } + break; + case "itemstate": + if (tokens.Count >= 3 && + int.TryParse(tokens[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int stateItemId) && + TryParseCubeState(tokens[2], out InteractCubeState itemState)) { + foreach (PlotCube target in plot.Cubes.Values.Where(x => x.ItemId == stateItemId)) { + if (TrySetCubeState(session, target, itemState)) { + changedStates++; + } + } + } else { + errors++; + } + break; + case "respawn": + if (TryParseCoord(tokens, 1, out Vector3B spawnCoord) && TryGetCube(plot, spawnCoord, out PlotCube? spawnCube) && + TryRespawnCube(session, spawnCube)) { + materialized++; + } else { + errors++; + } + break; + case "despawn": + if (TryParseCoord(tokens, 1, out Vector3B despawnCoord) && TryGetCube(plot, despawnCoord, out PlotCube? despawnCube) && + TryDespawnCube(session, despawnCube)) { + materialized++; + } else { + errors++; + } + break; + case "portal": + if (tokens.Count >= 8 && + TryParseCoord(tokens, 1, out Vector3B portalCoord) && + TryGetCube(plot, portalCoord, out PlotCube? portalCube) && + ApplyPortalCommand(session, plot, portalCube, tokens[4], tokens[5], tokens[6], string.Join(' ', tokens.Skip(7)))) { + portalUpdates++; + } else { + errors++; + } + break; + case "note": + if (tokens.Count >= 6 && + TryParseCoord(tokens, 1, out Vector3B noteCoord) && + TryGetCube(plot, noteCoord, out PlotCube? noteCube) && + byte.TryParse(tokens[4], NumberStyles.Integer, CultureInfo.InvariantCulture, out byte noticeDistance) && + ApplyNoticeCommand(session, noteCube, noticeDistance, string.Join(' ', tokens.Skip(5)))) { + noticeUpdates++; + } else { + errors++; + } + break; + case "var": + variables++; + break; + default: + errors++; + break; + } + } + + session.Housing.SaveHome(); + return $"电脑已保存:开关 {changedStates},实体 {materialized},传送 {portalUpdates},文字 {noticeUpdates},变量 {variables},错误 {errors}"; + } + + private static bool HandleCharmBuff(GameSession session, PlotCube cube, FieldFunctionInteract fieldInteract, FurnitureDefinition definition) { + if (cube.Interact is null) { + return false; + } + + fieldInteract.Use(); + + foreach (int buffId in CharmBuffIds) { + session.Player.Buffs.Remove(buffId, session.Player.ObjectId); + } + + if (definition.BuffId > 0) { + session.Player.Buffs.AddBuff(session.Player, session.Player, definition.BuffId, definition.BuffLevel, session.Field!.FieldTick, notifyField: true); + } + + cube.Interact.State = InteractCubeState.InUse; + cube.Interact.InteractingCharacterId = session.CharacterId; + session.Field!.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + session.Field.Broadcast(FunctionCubePacket.UseFurniture(session.CharacterId, cube.Interact)); + return true; + } + + private static bool HandleFunctionUi(GameSession session, PlotCube cube, FieldFunctionInteract fieldInteract) { + if (cube.Interact is null || session.Field is null) { + return false; + } + + EnsureConfigurableNotice(cube); + cube.Interact.State = InteractCubeState.InUse; + cube.Interact.InteractingCharacterId = session.CharacterId; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + session.Field.Broadcast(FunctionCubePacket.UseFurniture(session.CharacterId, cube.Interact)); + + if (IsSmartComputer(cube)) { + return OpenSmartComputerEditor(session, cube); + } + + bool ownerEditing = session.Field is HomeFieldManager homeField && homeField.OwnerId == session.AccountId; + session.Send(HomeActionPacket.SendCubeNoticeSettings(cube, editing: ownerEditing)); + return true; + } + + private static bool EnsureRuntimeInteract(FieldManager field, PlotCube cube, out FieldFunctionInteract? fieldInteract) { + EnsureInteract(cube, field.FunctionCubeMetadata); + fieldInteract = cube.Interact is null ? null : field.TryGetFieldFunctionInteract(cube.Interact.Id); + if (fieldInteract is not null) { + return true; + } + + fieldInteract = field.AddFieldFunctionInteract(cube); + return fieldInteract is not null; + } + + private static void EnsureConfigurableNotice(PlotCube cube) { + if (cube.Interact is null || cube.Interact.NoticeSettings is not null) { + return; + } + + cube.Interact.NoticeSettings = new CubeNoticeSettings(); + } + + private const string DefaultSmartComputerScript = + "# 简易智能电脑脚本\n" + + "# 每行一个命令,保存后立即应用\n" + + "# toggle x y z\n" + + "# state x y z available|inuse|none\n" + + "# respawn x y z\n" + + "# despawn x y z\n" + + "# portal x y z 传送门名 move 目标\n" + + "# note x y z 1 文本内容\n"; + + private static void EnsureSmartComputerTemplate(PlotCube cube) { + if (!IsSmartComputer(cube) || cube.Interact?.NoticeSettings is null || !string.IsNullOrWhiteSpace(cube.Interact.NoticeSettings.Notice)) { + return; + } + + cube.Interact.NoticeSettings.Notice = DefaultSmartComputerScript; + cube.Interact.NoticeSettings.Distance = 1; + } + + internal static bool TrySaveSmartComputerScript(GameSession session, string xml, out string message) { + message = string.Empty; + if (session.Field is null || session.Housing.GetFieldPlot() is not Plot plot) { + message = "当前不在房屋场景中。"; + return false; + } + + PlotCube? cube = plot.Cubes.Values.FirstOrDefault(x => x.Id == session.EditingSmartComputerCubeId && IsSmartComputer(x)); + if (cube is null) { + message = "未找到当前正在编辑的智能电脑。"; + return false; + } + + EnsureInteract(cube, session.FunctionCubeMetadata); + EnsureConfigurableNotice(cube); + cube.Interact!.NoticeSettings!.Notice = SanitizeSmartComputerScript(xml); + cube.Interact.NoticeSettings.Distance = 1; + + session.Housing.SaveHome(); + message = ApplyComputerScript(session, cube); + return true; + } + + internal static string GetSmartComputerScript(GameSession session) { + if (session.Housing.GetFieldPlot() is not Plot plot) { + return DefaultSmartComputerScript; + } + + PlotCube? cube = plot.Cubes.Values.FirstOrDefault(x => x.Id == session.EditingSmartComputerCubeId && IsSmartComputer(x)); + return SanitizeSmartComputerScript(cube?.Interact?.NoticeSettings?.Notice); + } + + private static string SanitizeSmartComputerScript(string? script) { + if (string.IsNullOrWhiteSpace(script)) { + return DefaultSmartComputerScript; + } + + return script + .TrimStart('\ufeff') + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Replace("\r", "\n", StringComparison.Ordinal); + } + + private static string GetComputerTriggerName(PlotCube cube) => $"home_user_trigger_{cube.Id}"; + + private static void TryInstallSavedComputerTrigger(FieldManager field, PlotCube cube) { + if (!IsSmartComputer(cube) || cube.Interact?.NoticeSettings is null) { + return; + } + + // 简易电脑版不再尝试加载原版 TriggerTool XML。 + // 脚本在玩家保存时即时应用,并以纯文本形式保存在 NoticeSettings 中。 + } + + private static bool ShouldSpawnTrapVisual(FurnitureDefinition definition) => definition.ItemId is 50300007 or 50300008 or 50300009 or 50300010; + + private static string GetTrapVisualEntityId(PlotCube cube) => $"housing_funcvis_{cube.Id}"; + + private static void EnsureTrapVisual(FieldManager field, PlotCube cube, FurnitureDefinition definition) { + if (!ShouldSpawnTrapVisual(definition)) { + return; + } + + string entityId = GetTrapVisualEntityId(cube); + if (field.TryGetInteract(entityId, out _)) { + return; + } + + Vector3 position = cube.Position; + Vector3 rotation = new(0, 0, cube.Rotation); + const int interactId = 99003007; + var mesh = new Ms2InteractMesh(interactId, position, rotation); + var meshObject = new InteractMeshObject(entityId, mesh) { + Model = "InteractMeshObject", + Asset = definition.DebugName, + NormalState = "Idle_A", + Reactable = "Idle_A", + Scale = 1f, + }; + + var metadata = new InteractObjectMetadata( + Id: interactId, + Type: InteractType.Mesh, + Collection: 0, + ReactCount: 0, + TargetPortalId: 0, + GuildPosterId: 0, + WeaponItemId: 0, + Item: new InteractObjectMetadataItem(0, 0, 0, 0, 0), + Time: new InteractObjectMetadataTime(0, 0, 0), + Drop: new InteractObjectMetadataDrop(0, Array.Empty(), Array.Empty(), 0, 0), + AdditionalEffect: new InteractObjectMetadataEffect( + Array.Empty(), + Array.Empty(), + 0, + 0), + Spawn: Array.Empty()); + + FieldInteract? fieldInteract = field.AddInteract(mesh, meshObject, metadata); + if (fieldInteract is null) { + return; + } + + field.Broadcast(InteractObjectPacket.Add(fieldInteract.Object)); + } + + private static bool EnsureNpcSpawned(FieldManager field, PlotCube cube, FurnitureDefinition definition) { + if (cube.Interact is null || definition.NpcId <= 0) { + return false; + } + + if (cube.Interact.SpawnedNpcObjectId != 0 && (field.Npcs.ContainsKey(cube.Interact.SpawnedNpcObjectId) || field.Mobs.ContainsKey(cube.Interact.SpawnedNpcObjectId))) { + return true; + } + + cube.Interact.SpawnedNpcObjectId = 0; + if (!field.NpcMetadata.TryGet(definition.NpcId, out NpcMetadata? npcMetadata)) { + return false; + } + + Vector3 spawnPosition = cube.Position + RotateOffset(cube.Rotation, new Vector3(0, 60, 0)); + Vector3 spawnRotation = new(0, 0, cube.Rotation); + FieldNpc? fieldNpc = field.SpawnNpc(npcMetadata, spawnPosition, spawnRotation, disableAi: true); + if (fieldNpc is null) { + return false; + } + + cube.Interact.SpawnedNpcObjectId = fieldNpc.ObjectId; + field.Broadcast(FieldPacket.AddNpc(fieldNpc)); + field.Broadcast(ProxyObjectPacket.AddNpc(fieldNpc)); + return true; + } + + private static bool TryToggleCube(GameSession session, PlotCube cube) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (cube.Interact is null) { + return false; + } + + InteractCubeState nextState = cube.Interact.State is InteractCubeState.InUse ? InteractCubeState.Available : InteractCubeState.InUse; + return TrySetCubeState(session, cube, nextState); + } + + private static bool TrySetCubeState(GameSession session, PlotCube cube, InteractCubeState state) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (cube.Interact is null) { + return false; + } + + EnsureRuntimeInteract(session.Field, cube, out _); + + cube.Interact.State = state; + cube.Interact.InteractingCharacterId = state is InteractCubeState.InUse ? session.CharacterId : 0; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + + if (TryGetDefinition(cube.Metadata, out FurnitureDefinition definition) && definition.Behavior is FurnitureBehavior.InstallNpc) { + if (state is InteractCubeState.None) { + Cleanup(session.Field, cube); + } else { + EnsureNpcSpawned(session.Field, cube, definition); + } + } + + return true; + } + + private static bool TryRespawnCube(GameSession session, PlotCube cube) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (!EnsureRuntimeInteract(session.Field, cube, out _)) { + return false; + } + + if (TryGetDefinition(cube.Metadata, out FurnitureDefinition definition) && definition.Behavior is FurnitureBehavior.InstallNpc) { + Cleanup(session.Field, cube); + return EnsureNpcSpawned(session.Field, cube, definition); + } + + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact!)); + return true; + } + + private static bool TryDespawnCube(GameSession session, PlotCube cube) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (cube.Interact is null) { + return false; + } + + Cleanup(session.Field, cube); + cube.Interact.State = InteractCubeState.None; + cube.Interact.InteractingCharacterId = 0; + session.Field.Broadcast(FunctionCubePacket.UpdateFunctionCube(cube.Interact)); + return true; + } + + private static bool ApplyPortalCommand(GameSession session, Plot plot, PlotCube cube, string portalName, string methodToken, string destinationToken, string destinationTarget) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (cube.Interact is null) { + return false; + } + + cube.Interact.PortalSettings ??= new CubePortalSettings(cube.Position); + cube.Interact.PortalSettings.PortalName = portalName; + cube.Interact.PortalSettings.Method = ParsePortalMethod(methodToken); + cube.Interact.PortalSettings.Destination = ParsePortalDestination(destinationToken); + cube.Interact.PortalSettings.DestinationTarget = destinationTarget; + + foreach (FieldPortal portal in session.Field.GetPortals()) { + session.Field.RemovePortal(portal.ObjectId); + } + + List cubePortals = plot.Cubes.Values + .Where(x => x.ItemId is Constant.InteriorPortalCubeId && x.Interact?.PortalSettings is not null) + .ToList(); + + foreach (PlotCube cubePortal in cubePortals) { + FieldPortal? fieldPortal = session.Field.SpawnCubePortal(cubePortal); + if (fieldPortal is null) { + continue; + } + + session.Field.Broadcast(PortalPacket.Add(fieldPortal)); + } + + return true; + } + + private static bool ApplyNoticeCommand(GameSession session, PlotCube cube, byte distance, string text) { + if (session.Field is null) { + return false; + } + + EnsureInteract(cube, session.Field.FunctionCubeMetadata); + if (cube.Interact is null) { + return false; + } + + EnsureConfigurableNotice(cube); + cube.Interact.NoticeSettings!.Distance = distance; + cube.Interact.NoticeSettings.Notice = text; + session.Field.Broadcast(HomeActionPacket.SendCubeNoticeSettings(cube, editing: false)); + return true; + } + + private static bool TryGetCube(Plot plot, Vector3B coord, out PlotCube? cube) => + plot.Cubes.TryGetValue(coord, out cube); + + private static bool TryParseCoord(IReadOnlyList tokens, int startIndex, out Vector3B coord) { + coord = default; + if (tokens.Count <= startIndex + 2) { + return false; + } + + if (!int.TryParse(tokens[startIndex], NumberStyles.Integer, CultureInfo.InvariantCulture, out int x) || + !int.TryParse(tokens[startIndex + 1], NumberStyles.Integer, CultureInfo.InvariantCulture, out int y) || + !int.TryParse(tokens[startIndex + 2], NumberStyles.Integer, CultureInfo.InvariantCulture, out int z)) { + return false; + } + + coord = new Vector3B(x, y, z); + return true; + } + + private static bool TryParseCubeState(string token, out InteractCubeState state) { + switch (token.Trim().ToLowerInvariant()) { + case "none": + case "0": + state = InteractCubeState.None; + return true; + case "available": + case "on": + case "1": + state = InteractCubeState.Available; + return true; + case "inuse": + case "active": + case "off": + case "2": + state = InteractCubeState.InUse; + return true; + default: + state = default; + return false; + } + } + + private static PortalActionType ParsePortalMethod(string token) { + if (byte.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out byte value) && Enum.IsDefined(typeof(PortalActionType), (int) value)) { + return (PortalActionType) value; + } + + return token.Trim().ToLowerInvariant() switch { + "touch" => PortalActionType.Touch, + _ => PortalActionType.Interact, + }; + } + + private static CubePortalDestination ParsePortalDestination(string token) { + if (byte.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out byte value) && Enum.IsDefined(typeof(CubePortalDestination), value)) { + return (CubePortalDestination) value; + } + + return token.Trim().ToLowerInvariant() switch { + "home" or "portalinhome" => CubePortalDestination.PortalInHome, + "map" or "selectedmap" => CubePortalDestination.SelectedMap, + "friend" or "friendhome" => CubePortalDestination.FriendHome, + _ => CubePortalDestination.PortalInHome, + }; + } + + private static List Tokenize(string line) { + var tokens = new List(); + var sb = new StringBuilder(); + bool inQuotes = false; + + foreach (char ch in line) { + if (ch == '"') { + inQuotes = !inQuotes; + continue; + } + + if (char.IsWhiteSpace(ch) && !inQuotes) { + if (sb.Length > 0) { + tokens.Add(sb.ToString()); + sb.Clear(); + } + continue; + } + + sb.Append(ch); + } + + if (sb.Length > 0) { + tokens.Add(sb.ToString()); + } + + return tokens; + } + + private static FunctionCubeMetadata CreateSyntheticMetadata(ItemMetadataInstall install, FurnitureDefinition definition) { + InteractCubeControlType controlType = definition.Behavior switch { + FurnitureBehavior.Trap => InteractCubeControlType.Skill, + FurnitureBehavior.InstallNpc => InteractCubeControlType.InstallNPC, + FurnitureBehavior.FunctionUi => InteractCubeControlType.FunctionUI, + FurnitureBehavior.CharmBuff => InteractCubeControlType.FunctionUI, + _ => InteractCubeControlType.None, + }; + + int recipeId = definition.Behavior switch { + FurnitureBehavior.Trap => definition.BuffId, + FurnitureBehavior.InstallNpc => definition.NpcId, + FurnitureBehavior.CharmBuff => definition.BuffId, + _ => 0, + }; + + ConfigurableCubeType configurableCubeType = definition.Behavior switch { + FurnitureBehavior.FunctionUi => ConfigurableCubeType.UGCNotice, + FurnitureBehavior.CharmBuff => ConfigurableCubeType.UGCNotice, + _ => ConfigurableCubeType.None, + }; + + return new FunctionCubeMetadata( + Id: install.ObjectCubeId != 0 ? install.ObjectCubeId : definition.ItemId, + RecipeId: recipeId, + ConfigurableCubeType: configurableCubeType, + DefaultState: InteractCubeState.Available, + ControlType: controlType, + AutoStateChange: new[] { (int) InteractCubeState.Available }, + AutoStateChangeTime: definition.AutoStateChangeTime, + Nurturing: null + ); + } + + private static int ResolveCharmBuffId(int itemId, int trophyId, int trophyLevel) { + if (CharmItemBuffOverrides.TryGetValue(itemId, out int exactBuffId)) { + return exactBuffId; + } + + int[] pool = trophyLevel switch { + <= 3 => LowTierCharmBuffs, + <= 6 => MidTierCharmBuffs, + <= 8 => HighTierCharmBuffs, + _ => TopTierCharmBuffs, + }; + + int index = Math.Abs((itemId * 31) ^ trophyId) % pool.Length; + return pool[index]; + } + + private static Vector3 RotateOffset(float rotationDegrees, Vector3 offset) { + float radians = MathF.PI * rotationDegrees / 180f; + float cos = MathF.Cos(radians); + float sin = MathF.Sin(radians); + return new Vector3( + offset.X * cos - offset.Y * sin, + offset.X * sin + offset.Y * cos, + offset.Z + ); + } +} diff --git a/Maple2.Server.Game/Util/Sync/PlayerInfoStorage.cs b/Maple2.Server.Game/Util/Sync/PlayerInfoStorage.cs index 751100069..bb3a4e9c6 100644 --- a/Maple2.Server.Game/Util/Sync/PlayerInfoStorage.cs +++ b/Maple2.Server.Game/Util/Sync/PlayerInfoStorage.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using Grpc.Core; +using Maple2.Database.Storage; using Maple2.Model.Enum; using Maple2.Model.Game; using Maple2.Server.Core.Sync; @@ -9,7 +10,7 @@ namespace Maple2.Server.Game.Util.Sync; -public class PlayerInfoStorage { +public class PlayerInfoStorage : IPlayerInfoProvider { private readonly WorldClient world; // TODO: Just using dictionary for now, might need eviction at some point (LRUCache) private readonly ConcurrentDictionary cache; @@ -26,6 +27,11 @@ public PlayerInfoStorage(WorldClient world) { listeners = new ConcurrentDictionary>(); } + + public PlayerInfo? GetPlayerInfo(long id) { + return GetOrFetch(id, out PlayerInfo? info) ? info : null; + } + public bool GetOrFetch(long characterId, [NotNullWhen(true)] out PlayerInfo? info) { if (cache.TryGetValue(characterId, out info)) { return true; @@ -83,10 +89,23 @@ public void Listen(long characterId, PlayerInfoListener listener) { } public void SendUpdate(PlayerUpdateRequest request) { - try { - //PlayerInfoCache - world.UpdatePlayer(request); - } catch (RpcException) { /* ignored */ } + // 对“上线态/频道/地图”更新非常关键,不能静默吞掉失败 + const int maxRetries = 3; + for (int i = 0; i < maxRetries; i++) { + try { + world.UpdatePlayer(request); + return; + } catch (RpcException ex) { + logger.Warning("SendUpdate(UpdatePlayer) failed attempt {Attempt}/{Max}. Status={Status}", + i + 1, maxRetries, ex.Status); + + // 小退避,避免瞬间打爆 + Thread.Sleep(200 * (i + 1)); + } catch (Exception ex) { + logger.Warning(ex, "SendUpdate(UpdatePlayer) unexpected failure"); + Thread.Sleep(200 * (i + 1)); + } + } } public bool ReceiveUpdate(PlayerUpdateRequest request) { diff --git a/Maple2.Server.Game/Util/TriggerStorage.cs b/Maple2.Server.Game/Util/TriggerStorage.cs index 6950be7ad..215d6036e 100644 --- a/Maple2.Server.Game/Util/TriggerStorage.cs +++ b/Maple2.Server.Game/Util/TriggerStorage.cs @@ -54,7 +54,17 @@ public bool TryGet(string mapXBlock, string triggerName, [NotNullWhen(true)] out return false; } + public static Trigger.Helpers.Trigger ParseXml(string xBlock, string triggerName, string xml) { + var document = new XmlDocument(); + document.LoadXml(xml); + return ParseTriggerDocument(xBlock, triggerName, document); + } + private Trigger.Helpers.Trigger ParseTrigger(string xBlock, string triggerName, XmlDocument doc) { + return ParseTriggerDocument(xBlock, triggerName, doc); + } + + private static Trigger.Helpers.Trigger ParseTriggerDocument(string xBlock, string triggerName, XmlDocument doc) { XmlElement? root = doc.DocumentElement; if (root is null || root.Name != "ms2") { throw new ArgumentException("Trigger XML must have a root element named ."); @@ -142,7 +152,7 @@ private Trigger.Helpers.Trigger ParseTrigger(string xBlock, string triggerName, return new Trigger.Helpers.Trigger(states); } - private LinkedList ParseActions(XmlNode parentNode, string stateName, string triggerName, string xBlock, string context) { + private static LinkedList ParseActions(XmlNode parentNode, string stateName, string triggerName, string xBlock, string context) { LinkedList actions = []; foreach (XmlNode actionNode in parentNode.SelectNodes("action")!) { string? actionName = actionNode.Attributes?["name"]?.Value; diff --git a/Maple2.Server.Game/Util/TriggerToolPacketProbe.cs b/Maple2.Server.Game/Util/TriggerToolPacketProbe.cs new file mode 100644 index 000000000..25bcca6bf --- /dev/null +++ b/Maple2.Server.Game/Util/TriggerToolPacketProbe.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Helpers; +using Maple2.Server.Core.Packets; +using Maple2.Server.Game.Session; +using Serilog; + +namespace Maple2.Server.Game.Util; + +internal static class TriggerToolPacketProbe { + internal const bool Enabled = true; + + private static readonly ILogger Logger = Log.Logger.ForContext(typeof(TriggerToolPacketProbe)); + private static readonly ConcurrentDictionary States = new(); + private static readonly string ProbeDir = Path.Combine(AppContext.BaseDirectory, "TriggerToolProbe"); + private const string DefaultScript = ""; + + private enum Stage { + Catalog15, + Open24, + Script11, + Done, + } + + private sealed class ProbeState { + public Stage Stage = Stage.Catalog15; + public bool Pending; + public bool HadError; + public int CubeCoordKey; + public string ScriptXml = DefaultScript; + } + + internal static bool TryProbe(GameSession session, int cubeCoordKey, string scriptXml) { + Directory.CreateDirectory(ProbeDir); + + ProbeState state = States.GetOrAdd(session.CharacterId, _ => new ProbeState()); + state.CubeCoordKey = cubeCoordKey; + state.ScriptXml = string.IsNullOrWhiteSpace(scriptXml) ? DefaultScript : scriptXml; + + if (state.Pending && !state.HadError) { + Logger.Information("[TriggerToolProbe] Stage {Stage} accepted by client, advancing.", state.Stage); + state.Stage = Next(state.Stage); + state.Pending = false; + } + + if (state.Stage == Stage.Done) { + Logger.Information("[TriggerToolProbe] All stages accepted. Probe files are in {Path}", ProbeDir); + return false; + } + + state.HadError = false; + state.Pending = true; + session.OnError = (_, debug) => OnError(session, state, debug); + + ByteWriter packet = BuildPacket(state); + Logger.Information("[TriggerToolProbe] Sending stage {Stage}. Probe file: {File}", state.Stage, GetFilePath(state.Stage)); + session.Send(packet); + return true; + } + + private static void OnError(GameSession session, ProbeState state, string debug) { + try { + SockExceptionInfo info = ErrorParserHelper.Parse(debug); + if (info.SendOp != SendOp.Trigger) { + return; + } + + state.HadError = true; + string line = BuildLine(state.Stage, info.Hint, state); + File.AppendAllText(GetFilePath(state.Stage), line + Environment.NewLine); + Logger.Information("[TriggerToolProbe] Stage {Stage} append => {Line} (offset {Offset}, hint {Hint})", state.Stage, line, info.Offset, info.Hint); + } catch (Exception ex) { + Logger.Warning(ex, "[TriggerToolProbe] Failed to parse client error: {Debug}", debug); + } + } + + private static ByteWriter BuildPacket(ProbeState state) { + ByteWriter pWriter = Packet.Of(SendOp.Trigger); + pWriter.WriteByte(GetCommand(state.Stage)); + + foreach (string line in ReadLines(state.Stage)) { + ApplyLine(pWriter, line, state); + } + + return pWriter; + } + + private static IEnumerable ReadLines(Stage stage) { + string path = GetFilePath(stage); + if (!File.Exists(path)) { + yield break; + } + + foreach (string line in File.ReadAllLines(path)) { + if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) { + continue; + } + yield return line.Trim(); + } + } + + private static void ApplyLine(IByteWriter pWriter, string line, ProbeState state) { + string[] parts = line.Split('|', 2); + string type = parts[0]; + string value = parts.Length > 1 ? parts[1] : string.Empty; + value = value + .Replace("{cubeCoordKey}", state.CubeCoordKey.ToString(CultureInfo.InvariantCulture)) + .Replace("{scriptXml}", state.ScriptXml); + + switch (type) { + case "Int": + pWriter.WriteInt(int.TryParse(value, out int i) ? i : 0); + break; + case "Short": + pWriter.WriteShort(short.TryParse(value, out short s) ? s : (short) 0); + break; + case "Byte": + pWriter.WriteByte(byte.TryParse(value, out byte b) ? b : (byte) 0); + break; + case "Long": + pWriter.WriteLong(long.TryParse(value, out long l) ? l : 0L); + break; + case "String": + pWriter.WriteString(value); + break; + case "UnicodeString": + pWriter.WriteUnicodeString(value); + break; + } + } + + private static string BuildLine(Stage stage, SockHint hint, ProbeState state) { + return hint switch { + SockHint.Decode1 => "Byte|0", + SockHint.Decode2 => "Short|0", + SockHint.Decode4 => DefaultInt(stage), + SockHint.Decode8 => "Long|0", + SockHint.Decodef => "Int|0", + SockHint.DecodeStrA => DefaultString(stage), + SockHint.DecodeStr => "UnicodeString|", + _ => "Byte|0", + }; + } + + private static string DefaultInt(Stage stage) { + return stage switch { + Stage.Open24 or Stage.Script11 => "Int|{cubeCoordKey}", + _ => "Int|0", + }; + } + + private static string DefaultString(Stage stage) { + return stage == Stage.Script11 ? "String|{scriptXml}" : "String|"; + } + + private static byte GetCommand(Stage stage) { + return stage switch { + Stage.Catalog15 => 15, + Stage.Open24 => 24, + Stage.Script11 => 11, + _ => 0, + }; + } + + private static Stage Next(Stage stage) { + return stage switch { + Stage.Catalog15 => Stage.Open24, + Stage.Open24 => Stage.Script11, + Stage.Script11 => Stage.Done, + _ => Stage.Done, + }; + } + + private static string GetFilePath(Stage stage) { + return Path.Combine(ProbeDir, $"Trigger_{GetCommand(stage):D2}.txt"); + } +} diff --git a/Maple2.Server.Web/Controllers/WebController.cs b/Maple2.Server.Web/Controllers/WebController.cs index 84b694b69..444d99390 100644 --- a/Maple2.Server.Web/Controllers/WebController.cs +++ b/Maple2.Server.Web/Controllers/WebController.cs @@ -286,7 +286,7 @@ private static IResult HandleUnknownMode(UgcType mode) { #endregion #region Ranking - public ByteWriter Trophy(string userName) { + private ByteWriter Trophy(string userName) { string cacheKey = $"Trophy_{userName ?? "all"}"; if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { @@ -312,7 +312,7 @@ public ByteWriter Trophy(string userName) { return result; } - public ByteWriter PersonalTrophy(long characterId) { + private ByteWriter PersonalTrophy(long characterId) { string cacheKey = $"PersonalTrophy_{characterId}"; if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { @@ -328,7 +328,7 @@ public ByteWriter PersonalTrophy(long characterId) { return result; } - public ByteWriter GuildTrophy(string userName) { + private ByteWriter GuildTrophy(string userName) { if (!string.IsNullOrEmpty(userName)) { string cacheKey = $"GuildTrophy_{userName}"; @@ -359,7 +359,7 @@ public ByteWriter GuildTrophy(string userName) { return InGameRankPacket.GuildTrophy(GetCachedGuildTrophyRankings()); } - public ByteWriter PersonalGuildTrophy(long characterId) { + private ByteWriter PersonalGuildTrophy(long characterId) { string cacheKey = $"PersonalGuildTrophy_{characterId}"; if (!cache.TryGetValue(cacheKey, out byte[]? cachedData)) { @@ -397,7 +397,7 @@ private IList GetCachedGuildTrophyRankings() { } #endregion - public ByteWriter MenteeList(long accountId, long characterId) { + private ByteWriter MenteeList(long accountId, long characterId) { using GameStorage.Request db = gameStorage.Context(); IList list = db.GetMentorList(accountId, characterId); diff --git a/Maple2.Server.World/Containers/GuildManager.cs b/Maple2.Server.World/Containers/GuildManager.cs index ef4030b2d..d41b42b99 100644 --- a/Maple2.Server.World/Containers/GuildManager.cs +++ b/Maple2.Server.World/Containers/GuildManager.cs @@ -100,7 +100,8 @@ public GuildError Join(string requestorName, PlayerInfo info) { return GuildError.s_guild_err_fail_addmember; } - // Broadcast before adding this new member. + // Add the new member before broadcasting so the applicant also receives the guild member sync. + Guild.Members.TryAdd(member.CharacterId, member); Broadcast(new GuildRequest { AddMember = new GuildRequest.Types.AddMember { CharacterId = member.CharacterId, @@ -109,7 +110,6 @@ public GuildError Join(string requestorName, PlayerInfo info) { JoinTime = member.JoinTime, }, }); - Guild.Members.TryAdd(member.CharacterId, member); return GuildError.none; } diff --git a/Maple2.Server.World/Service/WorldService.Migrate.cs b/Maple2.Server.World/Service/WorldService.Migrate.cs index ab28813eb..ef24074a2 100644 --- a/Maple2.Server.World/Service/WorldService.Migrate.cs +++ b/Maple2.Server.World/Service/WorldService.Migrate.cs @@ -21,6 +21,7 @@ public override Task MigrateOut(MigrateOutRequest request, S switch (request.Server) { case Server.Login: var loginEntry = new TokenEntry(request.Server, request.AccountId, request.CharacterId, new Guid(request.MachineId), 0, 0, 0, 0, 0, MigrationType.Normal); + worldServer.MarkMigrating(request.CharacterId, 45); tokenCache.Set(token, loginEntry, AuthExpiry); return Task.FromResult(new MigrateOutResponse { IpAddress = Target.LoginIp.ToString(), @@ -32,21 +33,26 @@ public override Task MigrateOut(MigrateOutRequest request, S throw new RpcException(new Status(StatusCode.Unavailable, $"No available game channels")); } - // Try to use requested channel or instanced channel - if (request.InstancedContent && channelClients.TryGetInstancedChannelId(out int channel)) { + // Channel selection priority: + // 1) If the Game server specifies a channel, keep it (even for instanced content). + // This avoids routing players to a dedicated/invalid instanced channel (often 0), + // which can desync presence and cause "fake offline" (client shows 65535). + // 2) Otherwise, if instanced content is requested, use an instanced channel if available. + // 3) Fallback to the first available channel. + + int channel; + if (request.HasChannel && channelClients.TryGetActiveEndpoint(request.Channel, out _)) { + channel = request.Channel; + } else if (request.InstancedContent && channelClients.TryGetInstancedChannelId(out channel)) { if (!channelClients.TryGetActiveEndpoint(channel, out _)) { throw new RpcException(new Status(StatusCode.Unavailable, "No available instanced game channel")); } - } else if (request.HasChannel && channelClients.TryGetActiveEndpoint(request.Channel, out _)) { - channel = request.Channel; } else { - // Fall back to first available channel channel = channelClients.FirstChannel(); if (channel == -1) { throw new RpcException(new Status(StatusCode.Unavailable, "No available game channels")); } } - if (!channelClients.TryGetActiveEndpoint(channel, out IPEndPoint? endpoint)) { throw new RpcException(new Status(StatusCode.Unavailable, $"Channel {channel} not found")); } @@ -77,6 +83,7 @@ public override Task MigrateIn(MigrateInRequest request, Serv } tokenCache.Remove(request.Token); + worldServer.ClearMigrating(data.CharacterId); return Task.FromResult(new MigrateInResponse { CharacterId = data.CharacterId, Channel = data.Channel, diff --git a/Maple2.Server.World/WorldServer.cs b/Maple2.Server.World/WorldServer.cs index e283c32de..9bd131cc1 100644 --- a/Maple2.Server.World/WorldServer.cs +++ b/Maple2.Server.World/WorldServer.cs @@ -30,6 +30,7 @@ public class WorldServer { private readonly EventQueue scheduler; private readonly CancellationTokenSource tokenSource = new(); private readonly ConcurrentDictionary memoryStringBoards; + private readonly System.Collections.Concurrent.ConcurrentDictionary _migratingUntil = new(); private static int _globalIdCounter; private readonly ILogger logger = Log.ForContext(); @@ -123,7 +124,31 @@ public void SetOffline(PlayerInfo playerInfo) { Async = true, }); } + public void MarkMigrating(long characterId, int seconds = 45) { + if (characterId == 0) return; + long until = DateTimeOffset.UtcNow.ToUnixTimeSeconds() + seconds; + _migratingUntil[characterId] = until; + } + + public void ClearMigrating(long characterId) { + if (characterId == 0) return; + _migratingUntil.TryRemove(characterId, out _); + } + + private bool IsMigrating(long characterId) { + if (characterId == 0) return false; + if (!_migratingUntil.TryGetValue(characterId, out long until)) { + return false; + } + + long now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + if (now <= until) return true; + + // expired + _migratingUntil.TryRemove(characterId, out _); + return false; + } private void Loop() { while (!tokenSource.Token.IsCancellationRequested) { try { diff --git a/Maple2.Tools/VectorMath/Transform.cs b/Maple2.Tools/VectorMath/Transform.cs index 65430220c..cd2a222e4 100644 --- a/Maple2.Tools/VectorMath/Transform.cs +++ b/Maple2.Tools/VectorMath/Transform.cs @@ -138,18 +138,44 @@ public void LookTo(Vector3 direction, bool snapToGroundPlane = true) { } public void LookTo(Vector3 direction, Vector3 up, bool snapToGroundPlane = true) { + // Defensive normalization: callers sometimes pass a zero vector (or already-invalid values), + // which would turn into NaN via Vector3.Normalize and then poison FrontAxis/RightAxis/UpAxis. + // That can later corrupt movement (e.g., skill cast keyframe movement) and ultimately navmesh queries. + if (float.IsNaN(direction.X) || float.IsNaN(direction.Y) || float.IsNaN(direction.Z) || + float.IsInfinity(direction.X) || float.IsInfinity(direction.Y) || float.IsInfinity(direction.Z) || + direction.LengthSquared() < 1e-6f) { + direction = FrontAxis; + } + if (float.IsNaN(up.X) || float.IsNaN(up.Y) || float.IsNaN(up.Z) || + float.IsInfinity(up.X) || float.IsInfinity(up.Y) || float.IsInfinity(up.Z) || + up.LengthSquared() < 1e-6f) { + up = new Vector3(0, 0, 1); + } + direction = Vector3.Normalize(direction); up = Vector3.Normalize(up); if (snapToGroundPlane) { - direction = Vector3.Normalize(direction - Vector3.Dot(direction, up) * up); // plane projection formula - - if (direction.IsNearlyEqual(new Vector3(0, 0, 0), 1e-3f)) { - direction = FrontAxis; + // Project direction onto plane defined by up. + Vector3 projected = direction - Vector3.Dot(direction, up) * up; + if (float.IsNaN(projected.X) || float.IsNaN(projected.Y) || float.IsNaN(projected.Z) || + float.IsInfinity(projected.X) || float.IsInfinity(projected.Y) || float.IsInfinity(projected.Z) || + projected.LengthSquared() < 1e-6f) { + projected = FrontAxis; } + direction = Vector3.Normalize(projected); } Vector3 right = Vector3.Cross(direction, up); + if (float.IsNaN(right.X) || float.IsNaN(right.Y) || float.IsNaN(right.Z) || + float.IsInfinity(right.X) || float.IsInfinity(right.Y) || float.IsInfinity(right.Z) || + right.LengthSquared() < 1e-6f) { + // Fallback: keep current basis if cross product degenerated. + right = RightAxis.LengthSquared() < 1e-6f ? Vector3.UnitX : Vector3.Normalize(RightAxis); + } else { + right = Vector3.Normalize(right); + } + up = Vector3.Cross(right, direction); float scale = Scale; diff --git a/config/survivallevel.xml b/config/survivallevel.xml new file mode 100644 index 000000000..b9f99126d --- /dev/null +++ b/config/survivallevel.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/survivallevelreward.xml b/config/survivallevelreward.xml new file mode 100644 index 000000000..f0bb69dba --- /dev/null +++ b/config/survivallevelreward.xml @@ -0,0 +1,303 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/survivalpassreward.xml b/config/survivalpassreward.xml new file mode 100644 index 000000000..b16b51b57 --- /dev/null +++ b/config/survivalpassreward.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/survivalpassreward_paid.xml b/config/survivalpassreward_paid.xml new file mode 100644 index 000000000..3b818ba04 --- /dev/null +++ b/config/survivalpassreward_paid.xml @@ -0,0 +1,73 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/config/survivalserverconfig.xml b/config/survivalserverconfig.xml new file mode 100644 index 000000000..ddc73e746 --- /dev/null +++ b/config/survivalserverconfig.xml @@ -0,0 +1,4 @@ + + + +