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.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/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..5ee325074 --- /dev/null +++ b/Maple2.Server.Game/Config/survivalpassreward.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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/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..67b7fbb1b 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) { @@ -951,6 +1038,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; diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs index 4e6cd94fc..3e09c844b 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.cs @@ -391,13 +391,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/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/StatsManager.cs b/Maple2.Server.Game/Manager/StatsManager.cs index 61c3122d8..97b109e54 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); 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..017bd24a5 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) { diff --git a/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs b/Maple2.Server.Game/Model/Field/Actor/ActorStateComponent/BattleState.cs index c3be812ce..17635a3c8 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) @@ -251,6 +257,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/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/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/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/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..4f45f45b6 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) { 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/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/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/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index d4644d6a6..599830dc7 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -283,7 +283,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..bfcbe027f 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? @@ -97,6 +101,7 @@ public static (DamageType, long) CalculateDamage(IActor caster, IActor target, D 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 +118,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,11 +128,15 @@ 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; } + if (ownedPetCaster is { OwnerId: > 0 }) { + attackTypeAmount = Math.Max(attackTypeAmount, GetOwnedPetAttackFactor(ownedPetCaster)); + } + damageMultiplier *= attackTypeAmount * resistance * (finalDamage == 0 ? 1 : finalDamage); attackDamage *= damageMultiplier * Constant.AttackDamageFactor + property.Value; @@ -143,4 +155,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/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.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 @@ + + + +