diff --git a/Maple2.File.Ingest/Mapper/ServerTableMapper.cs b/Maple2.File.Ingest/Mapper/ServerTableMapper.cs index 0a8043c75..940694898 100644 --- a/Maple2.File.Ingest/Mapper/ServerTableMapper.cs +++ b/Maple2.File.Ingest/Mapper/ServerTableMapper.cs @@ -785,42 +785,70 @@ private static ExpType ToExpType(AdventureExpType type) { } private TimeEventTable ParseTimeEventTable() { - var results = new Dictionary(); + var globalPortals = new Dictionary(); + var worldBosses = new Dictionary(); foreach ((int id, TimeEventData data) in parser.ParseTimeEventData()) { - // TODO: Handle other event types - if (data.type == TimeEventType.GlobalEvent) { - var entries = new GlobalPortalMetadata.Field[3]; // UI only supports 3 fields - entries[0] = new GlobalPortalMetadata.Field( - Name: data.eventName1, - MapId: data.eventField1.Length == 0 ? 0 : data.eventField1[0], - PortalId: data.eventField1.Length == 0 ? 0 : data.eventField1[1]); - entries[1] = new GlobalPortalMetadata.Field( - Name: data.eventName2, - MapId: data.eventField2.Length == 0 ? 0 : data.eventField2[0], - PortalId: data.eventField2.Length == 0 ? 0 : data.eventField2[1]); - entries[2] = new GlobalPortalMetadata.Field( - Name: data.eventName3, - MapId: data.eventField3.Length == 0 ? 0 : data.eventField3[0], - PortalId: data.eventField3.Length == 0 ? 0 : data.eventField3[1]); - int[] startTimeArray = ParseTimeToArray(data.startTime); - int[] endTimeArray = ParseTimeToArray(data.endTime); - int[] cycleArray = ParseTimeToArray(data.cycleTime); - int[] randomArray = ParseTimeToArray(data.randomTime); - int[] lifeArray = ParseTimeToArray(data.lifeTime); - results.Add(id, new GlobalPortalMetadata( - Id: id, - Probability: data.prob, - StartTime: new DateTime(startTimeArray[0], startTimeArray[1], startTimeArray[2], startTimeArray[3], startTimeArray[4], startTimeArray[5]), - EndTime: new DateTime(endTimeArray[0], endTimeArray[1], endTimeArray[2], endTimeArray[3], endTimeArray[4], endTimeArray[5]), - CycleTime: new TimeSpan(cycleArray[2], cycleArray[3], cycleArray[4], cycleArray[5]), - RandomTime: new TimeSpan(randomArray[2], randomArray[3], randomArray[4], randomArray[5]), - LifeTime: new TimeSpan(lifeArray[2], lifeArray[3], lifeArray[4], lifeArray[5]), - PopupMessage: data.popupMessage, - SoundId: data.soundID, - Entries: entries)); + switch (data.type) { + case TimeEventType.GlobalEvent: { + var entries = new GlobalPortalMetadata.Field[3]; // UI only supports 3 fields + entries[0] = new GlobalPortalMetadata.Field( + Name: data.eventName1, + MapId: data.eventField1.Length == 0 ? 0 : data.eventField1[0], + PortalId: data.eventField1.Length == 0 ? 0 : data.eventField1[1]); + entries[1] = new GlobalPortalMetadata.Field( + Name: data.eventName2, + MapId: data.eventField2.Length == 0 ? 0 : data.eventField2[0], + PortalId: data.eventField2.Length == 0 ? 0 : data.eventField2[1]); + entries[2] = new GlobalPortalMetadata.Field( + Name: data.eventName3, + MapId: data.eventField3.Length == 0 ? 0 : data.eventField3[0], + PortalId: data.eventField3.Length == 0 ? 0 : data.eventField3[1]); + int[] startTimeArray = ParseTimeToArray(data.startTime); + int[] endTimeArray = ParseTimeToArray(data.endTime); + int[] cycleArray = ParseTimeToArray(data.cycleTime); + int[] randomArray = ParseTimeToArray(data.randomTime); + int[] lifeArray = ParseTimeToArray(data.lifeTime); + globalPortals.Add(id, new GlobalPortalMetadata( + Id: id, + Probability: data.prob, + StartTime: new DateTime(startTimeArray[0], startTimeArray[1], startTimeArray[2], startTimeArray[3], startTimeArray[4], startTimeArray[5]), + EndTime: new DateTime(endTimeArray[0], endTimeArray[1], endTimeArray[2], endTimeArray[3], endTimeArray[4], endTimeArray[5]), + CycleTime: new TimeSpan(cycleArray[2], cycleArray[3], cycleArray[4], cycleArray[5]), + RandomTime: new TimeSpan(randomArray[2], randomArray[3], randomArray[4], randomArray[5]), + LifeTime: new TimeSpan(lifeArray[2], lifeArray[3], lifeArray[4], lifeArray[5]), + PopupMessage: data.popupMessage, + SoundId: data.soundID, + Entries: entries)); + break; + } + case TimeEventType.Boss: { + int[] startTimeArray = ParseTimeToArray(data.startTime); + int[] endTimeArray = ParseTimeToArray(data.endTime); + int[] cycleArray = string.IsNullOrEmpty(data.cycleTime) ? [0, 0, 0, 0, 0, 0] : ParseTimeToArray(data.cycleTime); + int[] randomArray = string.IsNullOrEmpty(data.randomTime) ? [0, 0, 0, 0, 0, 0] : ParseTimeToArray(data.randomTime); + int[] lifeArray = string.IsNullOrEmpty(data.lifeTime) ? [0, 0, 0, 0, 0, 0] : ParseTimeToArray(data.lifeTime); + worldBosses.Add(id, new WorldBossMetadata( + Id: id, + Probability: data.prob, + StartTime: new DateTime(startTimeArray[0], startTimeArray[1], startTimeArray[2], startTimeArray[3], startTimeArray[4], startTimeArray[5]), + EndTime: new DateTime(endTimeArray[0], endTimeArray[1], endTimeArray[2], endTimeArray[3], endTimeArray[4], endTimeArray[5]), + CycleTime: new TimeSpan(cycleArray[2], cycleArray[3], cycleArray[4], cycleArray[5]), + RandomTime: new TimeSpan(randomArray[2], randomArray[3], randomArray[4], randomArray[5]), + LifeTime: new TimeSpan(lifeArray[2], lifeArray[3], lifeArray[4], lifeArray[5]), + TargetMapIds: data.targetFields, + SpawnPointIds: data.targetSpawnPointIDs, + NpcIds: data.npcIDs, + Tag: data.tag, + Unique: data.unique, + IndividualChannelSpawn: data.individualChannelSpawn, + VariableCountByChannel: data.variableCountByChannelCount, + ScreenNotice: data.screenNotice, + ChatNotice: data.chatNotice)); + break; + } } } - return new TimeEventTable(results); + return new TimeEventTable(GlobalPortal: globalPortals, WorldBoss: worldBosses); int[] ParseTimeToArray(string time) { string[] timeArray = time.Split('-'); diff --git a/Maple2.Model/Enum/StringCode.cs b/Maple2.Model/Enum/StringCode.cs index 9f51fa3cf..8bb5431aa 100644 --- a/Maple2.Model/Enum/StringCode.cs +++ b/Maple2.Model/Enum/StringCode.cs @@ -2238,6 +2238,7 @@ public enum StringCode { s_interact_result_unknown = 2229, s_interact_result_mastery = 2230, s_interact_find_new_telescope = 2231, + [Description("{0} dealt the final blow against {1}.")] s_hunting_kill_boss = 2232, s_hunting_npc_kill_boss = 2233, s_mode_pvp_status_winner = 2234, @@ -3321,7 +3322,9 @@ public enum StringCode { s_word_tab_petcollect = 3308, s_word_tab_remakeoption = 3309, s_char_input_itemname = 3310, + [Description("$npc:{0}$ will leave soon.")] s_timeevent_boss_lifetimetext1 = 3311, + [Description("$npc:{0}$ disappeared.")] s_timeevent_boss_lifetimetext2 = 3312, s_word_pet = 3313, s_word_battle_pet = 3314, diff --git a/Maple2.Model/Game/WorldBoss.cs b/Maple2.Model/Game/WorldBoss.cs new file mode 100644 index 000000000..4e0347e5c --- /dev/null +++ b/Maple2.Model/Game/WorldBoss.cs @@ -0,0 +1,17 @@ +using Maple2.Model.Metadata; + +namespace Maple2.Model.Game; + +public class WorldBoss { + public int MetadataId => Metadata.Id; + public int Id; + public WorldBossMetadata Metadata; + public long EndTick; + public long SpawnTimestamp; + public long NextSpawnTimestamp; + + public WorldBoss(WorldBossMetadata metadata, int id) { + Metadata = metadata; + Id = id; + } +} diff --git a/Maple2.Model/Metadata/Constants.cs b/Maple2.Model/Metadata/Constants.cs index 7a0ec821c..43c74bf53 100644 --- a/Maple2.Model/Metadata/Constants.cs +++ b/Maple2.Model/Metadata/Constants.cs @@ -105,6 +105,10 @@ public static class Constant { public const int MinStatIntervalTick = 100; public const int HomePollMaxCount = 5; + public static readonly TimeSpan WorldBossIdleWarningThreshold = TimeSpan.FromMinutes(4); + public static readonly TimeSpan WorldBossDespawnThreshold = TimeSpan.FromMinutes(5); + public static readonly TimeSpan WorldBossMonitorInterval = TimeSpan.FromSeconds(30); + public const int MaxMentees = 3; public const long FurnishingBaseId = 2870000000000000000; diff --git a/Maple2.Model/Metadata/ServerTable/TimeEventTable.cs b/Maple2.Model/Metadata/ServerTable/TimeEventTable.cs index cb6ee1ce3..b8784535a 100644 --- a/Maple2.Model/Metadata/ServerTable/TimeEventTable.cs +++ b/Maple2.Model/Metadata/ServerTable/TimeEventTable.cs @@ -1,6 +1,26 @@ namespace Maple2.Model.Metadata; -public record TimeEventTable(IReadOnlyDictionary GlobalPortal) : ServerTable; +public record TimeEventTable( + IReadOnlyDictionary GlobalPortal, + IReadOnlyDictionary WorldBoss) : ServerTable; + +public record WorldBossMetadata( + int Id, + int Probability, + DateTime StartTime, + DateTime EndTime, + TimeSpan CycleTime, + TimeSpan RandomTime, + TimeSpan LifeTime, + int[] TargetMapIds, + int[] SpawnPointIds, + int[] NpcIds, + int Tag, + bool Unique, + bool IndividualChannelSpawn, + float VariableCountByChannel, + bool ScreenNotice, + bool ChatNotice); public record GlobalPortalMetadata( int Id, diff --git a/Maple2.Server.Core/proto/channel/channel.proto b/Maple2.Server.Core/proto/channel/channel.proto index 747e170bb..4a40fb5be 100644 --- a/Maple2.Server.Core/proto/channel/channel.proto +++ b/Maple2.Server.Core/proto/channel/channel.proto @@ -308,10 +308,30 @@ message TimeEventRequest { int32 room_id = 2; } + message AnnounceWorldBoss { + int32 metadata_id = 1; + int32 event_id = 2; + int64 end_tick = 3; + int64 next_spawn_timestamp = 4; + } + + message CloseWorldBoss { + int32 metadata_id = 1; + int32 event_id = 2; + } + + message WarnWorldBoss { + int32 metadata_id = 1; + int32 event_id = 2; + } + oneof TimeEvent { AnnounceGlobalPortal announce_global_portal = 1; CloseGlobalPortal close_global_portal = 2; GetField get_field = 3; + AnnounceWorldBoss announce_world_boss = 4; + CloseWorldBoss close_world_boss = 5; + WarnWorldBoss warn_world_boss = 6; } } diff --git a/Maple2.Server.Core/proto/world/world.proto b/Maple2.Server.Core/proto/world/world.proto index 4e9b96a4b..25ec06f35 100644 --- a/Maple2.Server.Core/proto/world/world.proto +++ b/Maple2.Server.Core/proto/world/world.proto @@ -498,17 +498,36 @@ message TimeEventRequest { message GetGlobalPortal {} + message GetActiveWorldBosses {} + + message WorldBossKilled { + int32 metadata_id = 1; + int32 event_id = 2; + int32 channel = 3; + } + oneof TimeEvent { JoinGlobalPortal join_global_portal = 1; GetGlobalPortal get_global_portal = 2; + GetActiveWorldBosses get_active_world_bosses = 3; + WorldBossKilled world_boss_killed = 4; } } message TimeEventResponse { + message ActiveWorldBoss { + int32 metadata_id = 1; + int32 event_id = 2; + int64 spawn_timestamp = 3; + int64 next_spawn_timestamp = 4; + repeated int32 alive_channels = 5; + } + oneof Info { GlobalPortalInfo global_portal_info = 1; } int32 error = 2; + repeated ActiveWorldBoss active_world_bosses = 3; } message GlobalPortalInfo { diff --git a/Maple2.Server.Game/Commands/NoticeCommand.cs b/Maple2.Server.Game/Commands/NoticeCommand.cs new file mode 100644 index 000000000..177ba4735 --- /dev/null +++ b/Maple2.Server.Game/Commands/NoticeCommand.cs @@ -0,0 +1,39 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using Maple2.Model.Enum; +using Maple2.Model.Game; +using Maple2.Server.Core.Packets; +using Maple2.Server.Game.Packets; +using Maple2.Server.Game.Session; + +namespace Maple2.Server.Game.Commands; + +public class NoticeCommand : GameCommand { + private readonly GameSession session; + + public NoticeCommand(GameSession session) : base(AdminPermissions.Debug, "notice", "Send a notice packet with a StringCode for testing.") { + this.session = session; + + var code = new Argument("code", "StringCode integer value"); + var args = new Argument("args", () => [], "Optional string arguments for the string code"); + var flag = new Option(["--flag", "-f"], () => (int) (NoticePacket.Flags.Message | NoticePacket.Flags.Alert), + "Notice flags (default: Message|Alert = 5)"); + + AddArgument(code); + AddArgument(args); + AddOption(flag); + + this.SetHandler(Handle, code, args, flag); + } + + private void Handle(InvocationContext ctx, int code, string[] args, int flag) { + if (!Enum.IsDefined(typeof(StringCode), code)) { + ctx.Console.WriteLine($"Unknown StringCode: {code}"); + return; + } + + var stringCode = (StringCode) code; + session.Send(NoticePacket.Message(new InterfaceText(stringCode, args), (NoticePacket.Flags) flag)); + ctx.Console.WriteLine($"Sent StringCode {code} ({stringCode}) with {args.Length} arg(s), flags={flag}"); + } +} diff --git a/Maple2.Server.Game/Commands/WorldBossCommand.cs b/Maple2.Server.Game/Commands/WorldBossCommand.cs new file mode 100644 index 000000000..4f9a4a93b --- /dev/null +++ b/Maple2.Server.Game/Commands/WorldBossCommand.cs @@ -0,0 +1,228 @@ +using System.CommandLine; +using System.CommandLine.Invocation; +using System.CommandLine.IO; +using System.Text; +using Maple2.Model.Enum; +using Maple2.Model.Metadata; +using Maple2.Server.Game.Session; +using Maple2.Server.Game.Util; +using Maple2.Server.World.Service; + +namespace Maple2.Server.Game.Commands; + +public class WorldBossCommand : GameCommand { + public WorldBossCommand(GameSession session) : base(AdminPermissions.Debug, "world-boss", "World boss debugging.") { + AddAlias("boss"); + AddCommand(new ListCommand(session)); + AddCommand(new SpawnCommand(session)); + AddCommand(new DespawnCommand(session)); + AddCommand(new WarpCommand(session)); + AddCommand(new NextCommand(session)); + } + + private class NextCommand : Command { + private readonly GameSession session; + + public NextCommand(GameSession session) : base("next", "Show the next world bosses that will spawn.") { + this.session = session; + this.SetHandler(Handle); + } + + private void Handle(InvocationContext ctx) { + IReadOnlyDictionary bosses = session.ServerTableMetadata.TimeEventTable.WorldBoss; + if (bosses.Count == 0) { + ctx.Console.Out.WriteLine("No world bosses found in metadata."); + return; + } + + // Query World for currently active bosses + TimeEventResponse response = session.World.TimeEvent(new TimeEventRequest { + GetActiveWorldBosses = new TimeEventRequest.Types.GetActiveWorldBosses(), + }); + Dictionary activeBosses = response.ActiveWorldBosses.ToDictionary(b => b.MetadataId); + + // Find next spawns for dead bosses + var nextSpawns = new List<(WorldBossMetadata Metadata, long NextSpawnTs)>(); + foreach ((int _, WorldBossMetadata metadata) in bosses.OrderBy(kv => kv.Key)) { + if (activeBosses.ContainsKey(metadata.Id)) continue; // skip alive + long nextTs = WorldBossUtil.ComputeNextSpawnTimestamp(metadata); + if (nextTs > 0) { + nextSpawns.Add((metadata, nextTs)); + } + } + + if (nextSpawns.Count == 0) { + ctx.Console.Out.WriteLine("No upcoming world boss spawns."); + ctx.ExitCode = 0; + return; + } + + var sb = new StringBuilder(); + sb.AppendLine($"{"ID",-6} {"NpcId",-10} {"Maps",-25} Next Spawn (UTC)"); + sb.AppendLine(new string('-', 55)); + + foreach ((WorldBossMetadata metadata, long nextTs) in nextSpawns.OrderBy(x => x.NextSpawnTs).Take(5)) { + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + string maps = string.Join(",", metadata.TargetMapIds); + string nextSpawn = DateTimeOffset.FromUnixTimeSeconds(nextTs).UtcDateTime.ToString("HH:mm:ss"); + sb.AppendLine($"{metadata.Id,-6} {npcId,-10} {maps,-25} {nextSpawn}"); + } + + ctx.Console.Out.WriteLine(sb.ToString()); + ctx.ExitCode = 0; + } + } + + /// List all world bosses with their active status and next spawn time. + private class ListCommand : Command { + private readonly GameSession session; + + public ListCommand(GameSession session) : base("list", "List all world bosses and their status.") { + this.session = session; + this.SetHandler(Handle); + } + + private void Handle(InvocationContext ctx) { + IReadOnlyDictionary bosses = session.ServerTableMetadata.TimeEventTable.WorldBoss; + if (bosses.Count == 0) { + ctx.Console.Out.WriteLine("No world bosses found in metadata."); + return; + } + + // Query World for currently active bosses + TimeEventResponse response = session.World.TimeEvent(new TimeEventRequest { + GetActiveWorldBosses = new TimeEventRequest.Types.GetActiveWorldBosses(), + }); + Dictionary activeBosses = response.ActiveWorldBosses.ToDictionary(b => b.MetadataId); + + var sb = new StringBuilder(); + sb.AppendLine($"{"ID",-6} {"NpcId",-10} {"Status",-10} {"Maps",-25} Next Spawn (UTC)"); + sb.AppendLine(new string('-', 75)); + + foreach ((int _, WorldBossMetadata metadata) in bosses.OrderBy(kv => kv.Key)) { + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + string maps = string.Join(",", metadata.TargetMapIds); + + string status; + string nextSpawn; + if (activeBosses.TryGetValue(metadata.Id, out TimeEventResponse.Types.ActiveWorldBoss? active)) { + status = "ALIVE"; + long nextTs = active.NextSpawnTimestamp; + nextSpawn = nextTs > 0 + ? DateTimeOffset.FromUnixTimeSeconds(nextTs).UtcDateTime.ToString("HH:mm:ss") + : "—"; + } else { + status = "dead"; + long nextTs = WorldBossUtil.ComputeNextSpawnTimestamp(metadata); + nextSpawn = nextTs > 0 + ? DateTimeOffset.FromUnixTimeSeconds(nextTs).UtcDateTime.ToString("HH:mm:ss") + : "expired"; + } + + sb.AppendLine($"{metadata.Id,-6} {npcId,-10} {status,-10} {maps,-25} {nextSpawn}"); + } + + ctx.Console.Out.WriteLine(sb.ToString()); + ctx.ExitCode = 0; + } + } + + /// Force spawn a specific field boss on the current map. + private class SpawnCommand : Command { + private readonly GameSession session; + + public SpawnCommand(GameSession session) : base("spawn", "Force spawn a field boss on the current map.") { + this.session = session; + + var metadataId = new Argument("id", "Metadata ID of the boss to spawn (from 'boss list')."); + AddArgument(metadataId); + this.SetHandler(Handle, metadataId); + } + + private void Handle(InvocationContext ctx, int metadataId) { + if (session.Field == null) { + ctx.Console.Error.WriteLine("No field loaded."); + return; + } + + if (!session.ServerTableMetadata.TimeEventTable.WorldBoss.TryGetValue(metadataId, out WorldBossMetadata? metadata)) { + ctx.Console.Error.WriteLine($"Unknown boss metadata ID: {metadataId}. Use 'boss list' to see valid IDs."); + return; + } + + if (!metadata.TargetMapIds.Contains(session.Field.MapId)) { + string validMaps = string.Join(", ", metadata.TargetMapIds); + ctx.Console.Error.WriteLine($"Boss {metadataId} does not spawn on map {session.Field.MapId}. Valid maps: {validMaps}"); + return; + } + + // Use eventId = 0 for debug spawns (no World coordination) + if (session.Field.SpawnWorldBoss(metadata, 0) == null) { + ctx.Console.Error.WriteLine($"Failed to spawn boss {metadataId} — spawn point not found or already active."); + return; + } + + ctx.Console.Out.WriteLine($"Spawned boss {metadataId} (NpcId: {(metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0)}) on map {session.Field.MapId}."); + ctx.ExitCode = 0; + } + } + + /// Force despawn the active world boss on the current map. + private class DespawnCommand : Command { + private readonly GameSession session; + + public DespawnCommand(GameSession session) : base("despawn", "Force despawn the active world boss on the current map.") { + this.session = session; + this.SetHandler(Handle); + } + + private void Handle(InvocationContext ctx) { + if (session.Field == null) { + ctx.Console.Error.WriteLine("No field loaded."); + return; + } + + session.Field.DespawnWorldBoss(); + ctx.Console.Out.WriteLine($"Despawned world boss on map {session.Field.MapId}."); + ctx.ExitCode = 0; + } + } + + /// Warp to a boss's target map. + private class WarpCommand : Command { + private readonly GameSession session; + + public WarpCommand(GameSession session) : base("warp", "Warp to a boss's target map.") { + this.session = session; + + var metadataId = new Argument("id", "Metadata ID of the boss to warp to."); + AddArgument(metadataId); + this.SetHandler(Handle, metadataId); + } + + private void Handle(InvocationContext ctx, int metadataId) { + if (!session.ServerTableMetadata.TimeEventTable.WorldBoss.TryGetValue(metadataId, out WorldBossMetadata? metadata)) { + ctx.Console.Error.WriteLine($"Unknown boss metadata ID: {metadataId}. Use 'boss list' to see valid IDs."); + return; + } + + int targetMap = metadata.TargetMapIds.Length > 0 ? metadata.TargetMapIds[0] : 0; + if (targetMap == 0) { + ctx.Console.Error.WriteLine($"Boss {metadataId} has no target map."); + return; + } + + bool success = session.PrepareField(targetMap); + session.Send(success + ? Packets.FieldEnterPacket.Request(session.Player) + : Packets.FieldEnterPacket.Error(Maple2.Model.Error.MigrationError.s_move_err_default)); + + if (success) { + ctx.Console.Out.WriteLine($"Warping to map {targetMap} for boss {metadataId}."); + } else { + ctx.Console.Error.WriteLine($"Failed to warp to map {targetMap}."); + } + ctx.ExitCode = 0; + } + } +} diff --git a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs index 0937b4aa6..aa6d5742d 100644 --- a/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs +++ b/Maple2.Server.Game/Manager/Field/FieldManager/FieldManager.State.cs @@ -13,6 +13,7 @@ using Maple2.Server.Game.LuaFunctions; using Maple2.Server.Game.Model; using Maple2.Server.Game.Model.Skill; +using Maple2.Server.Core.Packets; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; using Maple2.Server.Game.Util; @@ -21,7 +22,9 @@ using Maple2.Tools.DotRecast; using Maple2.Tools.Extensions; using Maple2.Tools.VectorMath; +using Maple2.Server.World.Service; using Serilog; +using WorldTimeEventRequest = Maple2.Server.World.Service.TimeEventRequest; namespace Maple2.Server.Game.Manager.Field; @@ -54,6 +57,8 @@ public partial class FieldManager { public IEnumerable DebugFieldSkills => fieldSkills.Values; public IEnumerable DebugCubeSkills => cubeSkills.Values; + private int worldBossObjectId; + private string? background; private readonly ConcurrentDictionary fieldProperties = new(); @@ -450,6 +455,76 @@ public void ToggleCombineSpawn(SpawnGroupMetadata metadata, bool enable) { fieldSpawnGroup.ToggleActive(enable); } + public FieldNpc? SpawnWorldBoss(WorldBossMetadata metadata, int eventId) { + if (metadata.NpcIds.Length == 0 || metadata.SpawnPointIds.Length == 0) { + logger.Warning("[WorldBoss] Metadata {Id} has no NpcIds or SpawnPointIds", metadata.Id); + return null; + } + + if (metadata.Unique && worldBossObjectId != 0) { + logger.Warning("[WorldBoss] Unique boss {Id} already present in map {MapId}, skipping spawn", metadata.Id, MapId); + return null; + } + + // TimeEventData.Xml only uses 1 npc and spawnpointid for each event + if (!NpcMetadata.TryGet(metadata.NpcIds.First(), out NpcMetadata? npcMetadata)) { + logger.Warning("[WorldBoss] NPC {NpcId} not found", metadata.NpcIds.First()); + return null; + } + + if (!Entities.RegionSpawns.TryGetValue(metadata.SpawnPointIds.First(), out Ms2RegionSpawn? regionSpawn)) { + logger.Warning("[WorldBoss] RegionSpawn {SpawnPointId} not found in map {MapId}", metadata.SpawnPointIds.First(), MapId); + return null; + } + + FieldNpc? npc = SpawnNpc(npcMetadata, regionSpawn.Position, regionSpawn.Rotation); + if (npc == null) { + return null; + } + + worldBossObjectId = npc.ObjectId; + npc.WorldBossDeathCallback = killedNpc => { + worldBossObjectId = 0; + FieldPlayer? killer = killedNpc.GetLastAttacker(); + string killerName = killer?.Value.Character.Name ?? string.Empty; + string bossName = npcMetadata.Name ?? string.Empty; + + Broadcast(NoticePacket.Message(new InterfaceText(StringCode.s_hunting_kill_boss, killerName, bossName))); + + // Notify World to remove this channel from the boss's alive channel list (for world map accuracy) + try { + FieldFactory.World.TimeEvent(new WorldTimeEventRequest { + WorldBossKilled = new WorldTimeEventRequest.Types.WorldBossKilled { + MetadataId = metadata.Id, + EventId = eventId, + Channel = GameServer.GetChannel(), + }, + }); + } catch (Exception ex) { + logger.Error(ex, "[WorldBoss] Failed to notify world of boss {MetadataId} kill on channel {Channel}", metadata.Id, GameServer.GetChannel()); + } + }; + Broadcast(FieldPacket.AddNpc(npc)); + logger.Information("[WorldBoss] Spawned {NpcId} (objectId={ObjectId}) for event {EventId} in map {MapId}", metadata.NpcIds[0], npc.ObjectId, eventId, MapId); + return npc; + } + + public FieldNpc? GetWorldBossNpc() { + if (worldBossObjectId == 0) return null; + Mobs.TryGetValue(worldBossObjectId, out FieldNpc? npc); + return npc; + } + + public void DespawnWorldBoss() { + if (worldBossObjectId == 0) { + return; + } + + int objectId = worldBossObjectId; + worldBossObjectId = 0; + RemoveNpc(objectId); + } + public void ToggleNpcSpawnPoint(int spawnId) { List spawns = fieldSpawnPointNpcs.Values.Where(spawn => spawn.Value.SpawnPointId == spawnId).ToList(); foreach (FieldSpawnPointNpc spawn in spawns) { diff --git a/Maple2.Server.Game/Model/Field/Actor/Actor.cs b/Maple2.Server.Game/Model/Field/Actor/Actor.cs index 427af9f97..088a3605d 100644 --- a/Maple2.Server.Game/Model/Field/Actor/Actor.cs +++ b/Maple2.Server.Game/Model/Field/Actor/Actor.cs @@ -151,6 +151,7 @@ public virtual void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadat record.AddDamage(DamageType.Normal, positiveDamage); Stats.Values[BasicAttribute.Health].Add(damageAmount); Field.Broadcast(StatsPacket.Update(this, BasicAttribute.Health)); + OnDamageReceived(caster, positiveDamage); } foreach ((DamageType damageType, long amount) in targetRecord.Damage) { @@ -174,6 +175,8 @@ public virtual void ApplyDamage(IActor caster, DamageRecord damage, SkillMetadat } } + protected virtual void OnDamageReceived(IActor caster, long amount) { } + public virtual void Reflect(IActor target) { if (Buffs.Reflect == null || Buffs.Reflect.Counter >= Buffs.Reflect.Metadata.Count) { return; diff --git a/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs b/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs index bb94336fe..969d6aa07 100644 --- a/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs +++ b/Maple2.Server.Game/Model/Field/Actor/FieldNpc.cs @@ -85,6 +85,9 @@ public short SequenceId { public readonly SkillMetadata?[] Skills; public int SpawnPointId = 0; + public Action? WorldBossDeathCallback { get; set; } + public long LastDamageTick { get; private set; } + private int lastAttackerObjectId; public MS2PatrolData? Patrol { get; private set; } private int currentWaypointIndex; @@ -315,7 +318,13 @@ public override void KeyframeEvent(string keyName) { return approachTask; } + protected override void OnDamageReceived(IActor caster, long amount) { + LastDamageTick = Environment.TickCount64; + lastAttackerObjectId = caster.ObjectId; + } + protected override void OnDeath() { + WorldBossDeathCallback?.Invoke(this); Owner?.Despawn(ObjectId); SendControl = false; @@ -487,6 +496,13 @@ public void ClearPatrolData() { currentWaypointIndex = 0; } + public FieldPlayer? GetLastAttacker() { + if (lastAttackerObjectId == 0 || !Field.TryGetPlayer(lastAttackerObjectId, out FieldPlayer? player)) { + return null; + } + return player; + } + public override string ToString() { return $"FieldNpc(Id: {Value.Metadata.Id}, Name: {Value.Metadata.Name}, State: {State}, SequenceId: {SequenceId}, SequenceCounter: {SequenceCounter})"; } diff --git a/Maple2.Server.Game/PacketHandlers/WorldMapHandler.cs b/Maple2.Server.Game/PacketHandlers/WorldMapHandler.cs index 9db3173c8..ec8446db5 100644 --- a/Maple2.Server.Game/PacketHandlers/WorldMapHandler.cs +++ b/Maple2.Server.Game/PacketHandlers/WorldMapHandler.cs @@ -1,9 +1,11 @@ using Maple2.Model.Game; +using Maple2.Model.Metadata; using Maple2.PacketLib.Tools; using Maple2.Server.Core.Constants; using Maple2.Server.Game.PacketHandlers.Field; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; +using Maple2.Server.World.Service; namespace Maple2.Server.Game.PacketHandlers; @@ -33,7 +35,22 @@ private static void HandleLoad(GameSession session, IByteReader packet) { // 102 = Victoria, 103 = Karkar, 105 = Kritias int mapCode = packet.ReadInt(); - session.Send(WorldMapPacket.Load(new List>(), new List())); + + TimeEventResponse bossResponse = session.World.TimeEvent(new TimeEventRequest { + GetActiveWorldBosses = new TimeEventRequest.Types.GetActiveWorldBosses(), + }); + + var bossGroups = new List>(); + foreach (TimeEventResponse.Types.ActiveWorldBoss active in bossResponse.ActiveWorldBosses) { + if (!session.ServerTableMetadata.TimeEventTable.WorldBoss.TryGetValue(active.MetadataId, out WorldBossMetadata? metadata)) { + continue; + } + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + int mapId = metadata.TargetMapIds.Length > 0 ? metadata.TargetMapIds[0] : 0; + bossGroups.Add(active.AliveChannels.Select(ch => new MapWorldBoss(npcId, mapId, (short) ch, active.SpawnTimestamp)).ToList()); + } + + session.Send(WorldMapPacket.Load(bossGroups, [])); } private static void HandlePopulation(GameSession session, IByteReader packet) { diff --git a/Maple2.Server.Game/Packets/LegionBattlePacket.cs b/Maple2.Server.Game/Packets/LegionBattlePacket.cs new file mode 100644 index 000000000..e29253124 --- /dev/null +++ b/Maple2.Server.Game/Packets/LegionBattlePacket.cs @@ -0,0 +1,35 @@ +using Maple2.Model.Metadata; +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Packets; +using Maple2.Server.Game.Util; + +namespace Maple2.Server.Game.Packets; + +public static class LegionBattlePacket { + private enum Mode : byte { + Load = 0, + Update = 1, + } + + public static ByteWriter Load(IReadOnlyDictionary bosses) { + ByteWriter pWriter = Packet.Of(SendOp.LegionBattle); + pWriter.Write(Mode.Load); + pWriter.WriteShort((short) bosses.Count); + foreach ((int _, WorldBossMetadata metadata) in bosses) { + pWriter.WriteInt(metadata.Id); + pWriter.WriteInt(metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0); + pWriter.WriteLong(WorldBossUtil.ComputeNextSpawnTimestamp(metadata)); + } + return pWriter; + } + + public static ByteWriter Update(int eventId, int npcId, long nextSpawnTimestamp) { + ByteWriter pWriter = Packet.Of(SendOp.LegionBattle); + pWriter.Write(Mode.Update); + pWriter.WriteInt(eventId); + pWriter.WriteInt(npcId); + pWriter.WriteLong(nextSpawnTimestamp); + return pWriter; + } +} diff --git a/Maple2.Server.Game/Packets/WorldShareInfoPacket.cs b/Maple2.Server.Game/Packets/WorldShareInfoPacket.cs new file mode 100644 index 000000000..a48cece57 --- /dev/null +++ b/Maple2.Server.Game/Packets/WorldShareInfoPacket.cs @@ -0,0 +1,22 @@ +using Maple2.PacketLib.Tools; +using Maple2.Server.Core.Constants; +using Maple2.Server.Core.Packets; + +namespace Maple2.Server.Game.Packets; + +public static class WorldShareInfoPacket { + private enum Function : byte { + BossAlive = 0, + } + + public static ByteWriter BossAlive(int npcId, int mapId, short channel, long timestamp, bool alive) { + var pWriter = Packet.Of(SendOp.WorldShareInfo); + pWriter.Write(Function.BossAlive); + pWriter.WriteInt(npcId); + pWriter.WriteInt(mapId); + pWriter.WriteShort(channel); + pWriter.WriteLong(timestamp); + pWriter.WriteBool(alive); + return pWriter; + } +} diff --git a/Maple2.Server.Game/Service/ChannelService.TimeEvent.cs b/Maple2.Server.Game/Service/ChannelService.TimeEvent.cs index 99a0b6ce4..364547f65 100644 --- a/Maple2.Server.Game/Service/ChannelService.TimeEvent.cs +++ b/Maple2.Server.Game/Service/ChannelService.TimeEvent.cs @@ -1,14 +1,19 @@ -using Grpc.Core; +using System.Collections.Concurrent; +using Grpc.Core; using Maple2.Model.Enum; +using Maple2.Model.Game; using Maple2.Model.Metadata; using Maple2.Server.Channel.Service; +using Maple2.Server.Core.Packets; using Maple2.Server.Game.Manager.Field; +using Maple2.Server.Game.Model; using Maple2.Server.Game.Packets; using Maple2.Server.Game.Session; namespace Maple2.Server.Game.Service; public partial class ChannelService { + private readonly ConcurrentDictionary monitoringBossFields = new(); public override Task TimeEvent(TimeEventRequest request, ServerCallContext context) { switch (request.TimeEventCase) { case TimeEventRequest.TimeEventOneofCase.AnnounceGlobalPortal: @@ -17,6 +22,12 @@ public override Task TimeEvent(TimeEventRequest request, Serv return Task.FromResult(CloseGlobalPortal(request.CloseGlobalPortal)); case TimeEventRequest.TimeEventOneofCase.GetField: return Task.FromResult(GetField(request.GetField)); + case TimeEventRequest.TimeEventOneofCase.AnnounceWorldBoss: + return Task.FromResult(AnnounceWorldBoss(request.AnnounceWorldBoss)); + case TimeEventRequest.TimeEventOneofCase.CloseWorldBoss: + return Task.FromResult(CloseWorldBoss(request.CloseWorldBoss)); + case TimeEventRequest.TimeEventOneofCase.WarnWorldBoss: + return Task.FromResult(WarnWorldBoss(request.WarnWorldBoss)); default: return Task.FromResult(new TimeEventResponse()); } @@ -41,6 +52,110 @@ private TimeEventResponse CloseGlobalPortal(TimeEventRequest.Types.CloseGlobalPo return new TimeEventResponse(); } + private TimeEventResponse AnnounceWorldBoss(TimeEventRequest.Types.AnnounceWorldBoss boss) { + if (!serverTableMetadata.TimeEventTable.WorldBoss.TryGetValue(boss.MetadataId, out WorldBossMetadata? metadata)) { + return new TimeEventResponse(); + } + + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + int mapId = metadata.TargetMapIds.Length > 0 ? metadata.TargetMapIds[0] : 0; + short channel = GameServer.GetChannel(); + long spawnTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + foreach (GameSession session in server.GetSessions()) { + session.Send(WorldShareInfoPacket.BossAlive(npcId, mapId, channel, spawnTimestamp, true)); + session.Send(LegionBattlePacket.Update(boss.EventId, npcId, boss.NextSpawnTimestamp)); + } + + // Spawn the boss NPC in each target map + foreach (int targetMapId in metadata.TargetMapIds) { + FieldManager? field = server.GetField(targetMapId); + field?.SpawnWorldBoss(metadata, boss.EventId); + } + + return new TimeEventResponse(); + } + + private TimeEventResponse WarnWorldBoss(TimeEventRequest.Types.WarnWorldBoss boss) { + if (!serverTableMetadata.TimeEventTable.WorldBoss.TryGetValue(boss.MetadataId, out WorldBossMetadata? metadata)) { + return new TimeEventResponse(); + } + + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + + foreach (int targetMapId in metadata.TargetMapIds) { + FieldManager? field = server.GetField(targetMapId); + if (field == null) continue; + FieldNpc? bossNpc = field.GetWorldBossNpc(); + if (bossNpc == null) continue; // Already dead on this channel + field.Broadcast(NoticePacket.Message(new InterfaceText(StringCode.s_timeevent_boss_lifetimetext1, npcId.ToString()))); + } + + return new TimeEventResponse(); + } + + private TimeEventResponse CloseWorldBoss(TimeEventRequest.Types.CloseWorldBoss boss) { + if (!serverTableMetadata.TimeEventTable.WorldBoss.TryGetValue(boss.MetadataId, out WorldBossMetadata? metadata)) { + return new TimeEventResponse(); + } + + int npcId = metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0; + + foreach (int targetMapId in metadata.TargetMapIds) { + FieldManager? field = server.GetField(targetMapId); + if (field == null) continue; + + FieldNpc? bossNpc = field.GetWorldBossNpc(); + if (bossNpc == null) continue; + + long idleMs = bossNpc.LastDamageTick > 0 + ? Environment.TickCount64 - bossNpc.LastDamageTick + : long.MaxValue; + + if (idleMs < (long) Constant.WorldBossIdleWarningThreshold.TotalMilliseconds) { + if (monitoringBossFields.TryAdd(targetMapId, 0)) { + _ = MonitorExtendedBossLifetimeAsync(field, npcId, targetMapId); + } + } else { + field.Broadcast(NoticePacket.Message(new InterfaceText(StringCode.s_timeevent_boss_lifetimetext2, npcId.ToString()))); + field.DespawnWorldBoss(); + } + } + + return new TimeEventResponse(); + } + + private async Task MonitorExtendedBossLifetimeAsync(FieldManager field, int npcId, int targetMapId) { + bool warningSent = false; + try { + while (true) { + await Task.Delay(Constant.WorldBossMonitorInterval); + + FieldNpc? bossNpc = field.GetWorldBossNpc(); + if (bossNpc == null) return; + + long idleMs = bossNpc.LastDamageTick == 0 + ? long.MaxValue + : Environment.TickCount64 - bossNpc.LastDamageTick; + + if (!warningSent && idleMs >= (long) Constant.WorldBossIdleWarningThreshold.TotalMilliseconds) { + field.Broadcast(NoticePacket.Message(new InterfaceText(StringCode.s_timeevent_boss_lifetimetext1, npcId.ToString()))); + warningSent = true; + } + + if (idleMs >= (long) Constant.WorldBossDespawnThreshold.TotalMilliseconds) { + field.Broadcast(NoticePacket.Message(new InterfaceText(StringCode.s_timeevent_boss_lifetimetext2, npcId.ToString()))); + field.DespawnWorldBoss(); + return; + } + } + } catch (Exception ex) { + logger.Error(ex, "Error monitoring field boss lifetime for NPC {NpcId}", npcId); + } finally { + monitoringBossFields.TryRemove(targetMapId, out _); + } + } + private TimeEventResponse GetField(TimeEventRequest.Types.GetField field) { FieldManager? manager = server.GetField(field.MapId, field.RoomId); if (manager == null) { @@ -59,4 +174,3 @@ private TimeEventResponse GetField(TimeEventRequest.Types.GetField field) { }; } } - diff --git a/Maple2.Server.Game/Service/ChannelService.cs b/Maple2.Server.Game/Service/ChannelService.cs index b3b9e3449..67153e161 100644 --- a/Maple2.Server.Game/Service/ChannelService.cs +++ b/Maple2.Server.Game/Service/ChannelService.cs @@ -10,7 +10,6 @@ public partial class ChannelService : Channel.Service.Channel.ChannelBase { private readonly GameStorage gameStorage; private readonly TableMetadataStorage tableMetadata; private readonly ServerTableMetadataStorage serverTableMetadata; - private readonly ILogger logger = Log.Logger.ForContext(); public ChannelService(GameServer server, PlayerInfoStorage playerInfos, GameStorage gameStorage, ServerTableMetadataStorage serverTableMetadata, TableMetadataStorage tableMetadata, ItemMetadataStorage itemMetadata) { diff --git a/Maple2.Server.Game/Session/GameSession.cs b/Maple2.Server.Game/Session/GameSession.cs index cc73010da..d4644d6a6 100644 --- a/Maple2.Server.Game/Session/GameSession.cs +++ b/Maple2.Server.Game/Session/GameSession.cs @@ -326,7 +326,26 @@ public bool EnterServer(long accountId, Guid machineId, MigrateInResponse migrat Send(FishingPacket.LoadAlbum(Player.Value.Unlock.FishAlbum.Values)); Pet?.Load(); Send(PetPacket.LoadCollection(Player.Value.Unlock.Pets)); - // LegionBattle + Send(LegionBattlePacket.Load(ServerTableMetadata.TimeEventTable.WorldBoss)); + try { + TimeEventResponse bossResponse = World.TimeEvent(new TimeEventRequest { + GetActiveWorldBosses = new TimeEventRequest.Types.GetActiveWorldBosses(), + }); + if (bossResponse.ActiveWorldBosses.Count > 0) { + var bossGroups = new List>(); + foreach (TimeEventResponse.Types.ActiveWorldBoss active in bossResponse.ActiveWorldBosses) { + if (!ServerTableMetadata.TimeEventTable.WorldBoss.TryGetValue(active.MetadataId, out WorldBossMetadata? bossMetadata)) { + continue; + } + int bossNpcId = bossMetadata.NpcIds.Length > 0 ? bossMetadata.NpcIds[0] : 0; + int bossMapId = bossMetadata.TargetMapIds.Length > 0 ? bossMetadata.TargetMapIds[0] : 0; + bossGroups.Add(active.AliveChannels.Select(ch => new MapWorldBoss(bossNpcId, bossMapId, (short) ch, active.SpawnTimestamp)).ToList()); + } + Send(WorldMapPacket.Load(bossGroups, [])); + } + } catch (RpcException ex) { + Logger.Warning(ex, "Failed to fetch active field bosses"); + } // CharacterAbility Config.LoadKeyTable(); Send(GuideRecordPacket.Load(Config.GuideRecords)); diff --git a/Maple2.Server.Game/Util/WorldBossUtil.cs b/Maple2.Server.Game/Util/WorldBossUtil.cs new file mode 100644 index 000000000..8500bc355 --- /dev/null +++ b/Maple2.Server.Game/Util/WorldBossUtil.cs @@ -0,0 +1,18 @@ +using Maple2.Model.Metadata; + +namespace Maple2.Server.Game.Util; + +public static class WorldBossUtil { + public static long ComputeNextSpawnTimestamp(WorldBossMetadata metadata) { + if (metadata.EndTime < DateTime.Now || metadata.CycleTime == TimeSpan.Zero) { + return 0; + } + DateTime next = metadata.StartTime; + if (next < DateTime.Now) { + double elapsedMs = (DateTime.Now - metadata.StartTime).TotalMilliseconds; + long cycles = (long) Math.Ceiling(elapsedMs / metadata.CycleTime.TotalMilliseconds); + next = metadata.StartTime + TimeSpan.FromMilliseconds(cycles * metadata.CycleTime.TotalMilliseconds); + } + return next > metadata.EndTime ? 0 : new DateTimeOffset(next).ToUnixTimeSeconds(); + } +} diff --git a/Maple2.Server.World/Containers/WorldBossLookup.cs b/Maple2.Server.World/Containers/WorldBossLookup.cs new file mode 100644 index 000000000..7f7914e46 --- /dev/null +++ b/Maple2.Server.World/Containers/WorldBossLookup.cs @@ -0,0 +1,49 @@ +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Maple2.Model.Metadata; + +namespace Maple2.Server.World.Containers; + +public class WorldBossLookup { + private readonly ChannelClientLookup channelClients; + private readonly ConcurrentDictionary activeManagers = new(); + private int nextEventId = 1; + + public WorldBossLookup(ChannelClientLookup channelClients) { + this.channelClients = channelClients; + } + + public bool TryGet(int metadataId, [NotNullWhen(true)] out WorldBossManager? manager) { + return activeManagers.TryGetValue(metadataId, out manager); + } + + public IEnumerable GetAll() => activeManagers.Values; + + public bool Create(WorldBossMetadata metadata, long endTick, long nextSpawnTimestamp, out int eventId) { + int id = Interlocked.Increment(ref nextEventId); + var manager = new WorldBossManager(metadata, id, endTick, nextSpawnTimestamp) { + ChannelClients = channelClients, + }; + + if (!activeManagers.TryAdd(metadata.Id, manager)) { + eventId = 0; + return false; + } + + eventId = id; + return true; + } + + public void RemoveChannel(int metadataId, short channel) { + if (activeManagers.TryGetValue(metadataId, out WorldBossManager? manager)) { + manager.RemoveChannel(channel); + } + } + + public void Dispose(int metadataId) { + if (!activeManagers.TryRemove(metadataId, out WorldBossManager? manager)) { + return; + } + manager.Dispose(); + } +} diff --git a/Maple2.Server.World/Containers/WorldBossManager.cs b/Maple2.Server.World/Containers/WorldBossManager.cs new file mode 100644 index 000000000..a6ae4bee2 --- /dev/null +++ b/Maple2.Server.World/Containers/WorldBossManager.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using Grpc.Core; +using Maple2.Model.Game; +using Maple2.Model.Metadata; +using Maple2.Server.Channel.Service; +using Serilog; +using ChannelClient = Maple2.Server.Channel.Service.Channel.ChannelClient; + +namespace Maple2.Server.World.Containers; + +public class WorldBossManager : IDisposable { + public required ChannelClientLookup ChannelClients { get; init; } + + public readonly WorldBoss Boss; + public readonly ConcurrentDictionary AliveChannels = new(); + + public WorldBossManager(WorldBossMetadata metadata, int id, long endTick, long nextSpawnTimestamp) { + Boss = new WorldBoss(metadata, id) { + EndTick = endTick, + NextSpawnTimestamp = nextSpawnTimestamp, + }; + } + + public void RemoveChannel(int channel) => AliveChannels.TryRemove(channel, out _); + + public void Announce() { + foreach ((int channelId, ChannelClient channelClient) in ChannelClients) { + try { + channelClient.TimeEvent(new TimeEventRequest { + AnnounceWorldBoss = new TimeEventRequest.Types.AnnounceWorldBoss { + MetadataId = Boss.MetadataId, + EventId = Boss.Id, + EndTick = Boss.EndTick, + NextSpawnTimestamp = Boss.NextSpawnTimestamp, + }, + }); + + AliveChannels.TryAdd(channelId, 0); + } catch (RpcException rpcException) { + if (rpcException.StatusCode == StatusCode.Unavailable) { + Log.Warning("Channel {Channel} unavailable when announcing field boss {BossId}", channelId, Boss.MetadataId); + continue; + } + Log.Error(rpcException, "Error announcing field boss {BossId} to channel {Channel}", Boss.MetadataId, channelId); + } + } + } + + public void WarnChannels() { + foreach ((int channelId, ChannelClient channelClient) in ChannelClients) { + try { + channelClient.TimeEvent(new TimeEventRequest { + WarnWorldBoss = new TimeEventRequest.Types.WarnWorldBoss { + MetadataId = Boss.MetadataId, + EventId = Boss.Id, + }, + }); + } catch (RpcException rpcException) { + if (rpcException.StatusCode == StatusCode.Unavailable) { + Log.Warning("Channel {Channel} unavailable when warning field boss {BossId}", channelId, Boss.MetadataId); + continue; + } + Log.Error(rpcException, "Error warning field boss {BossId} on channel {Channel}", Boss.MetadataId, channelId); + } + } + } + + public void Dispose() { + foreach ((int channelId, ChannelClient channelClient) in ChannelClients) { + try { + channelClient.TimeEvent(new TimeEventRequest { + CloseWorldBoss = new TimeEventRequest.Types.CloseWorldBoss { + MetadataId = Boss.MetadataId, + EventId = Boss.Id, + }, + }); + } catch (RpcException rpcException) { + if (rpcException.StatusCode == StatusCode.Unavailable) { + Log.Warning("Channel {Channel} unavailable when closing field boss {BossId}", channelId, Boss.MetadataId); + continue; + } + Log.Error(rpcException, "Error closing field boss {BossId} on channel {Channel}", Boss.MetadataId, channelId); + } + } + } +} diff --git a/Maple2.Server.World/Program.cs b/Maple2.Server.World/Program.cs index 6d34eafcd..1120e7481 100644 --- a/Maple2.Server.World/Program.cs +++ b/Maple2.Server.World/Program.cs @@ -75,6 +75,8 @@ .SingleInstance(); autofac.RegisterType() .SingleInstance(); + autofac.RegisterType() + .SingleInstance(); autofac.RegisterType() .SingleInstance(); diff --git a/Maple2.Server.World/Service/WorldService.TimeEvent.cs b/Maple2.Server.World/Service/WorldService.TimeEvent.cs index 8d785aa7c..a344f9e71 100644 --- a/Maple2.Server.World/Service/WorldService.TimeEvent.cs +++ b/Maple2.Server.World/Service/WorldService.TimeEvent.cs @@ -11,11 +11,35 @@ public override Task TimeEvent(TimeEventRequest request, Serv return Task.FromResult(JoinGlobalPortal(request.JoinGlobalPortal)); case TimeEventRequest.TimeEventOneofCase.GetGlobalPortal: return Task.FromResult(GetGlobalPortal(request.GetGlobalPortal)); + case TimeEventRequest.TimeEventOneofCase.GetActiveWorldBosses: + return Task.FromResult(GetActiveWorldBosses()); + case TimeEventRequest.TimeEventOneofCase.WorldBossKilled: + return Task.FromResult(OnWorldBossKilled(request.WorldBossKilled)); default: return Task.FromResult(new TimeEventResponse()); } } + private TimeEventResponse OnWorldBossKilled(TimeEventRequest.Types.WorldBossKilled kill) { + worldBossLookup.RemoveChannel(kill.MetadataId, (short) kill.Channel); + return new TimeEventResponse(); + } + + private TimeEventResponse GetActiveWorldBosses() { + var response = new TimeEventResponse(); + foreach (WorldBossManager manager in worldBossLookup.GetAll()) { + var entry = new TimeEventResponse.Types.ActiveWorldBoss { + MetadataId = manager.Boss.MetadataId, + EventId = manager.Boss.Id, + SpawnTimestamp = manager.Boss.SpawnTimestamp, + NextSpawnTimestamp = manager.Boss.NextSpawnTimestamp, + }; + entry.AliveChannels.AddRange(manager.AliveChannels.Keys); + response.ActiveWorldBosses.Add(entry); + } + return response; + } + private TimeEventResponse JoinGlobalPortal(TimeEventRequest.Types.JoinGlobalPortal portal) { if (!globalPortalLookup.TryGet(out GlobalPortalManager? manager) || manager.Portal.MetadataId != portal.EventId) { return new TimeEventResponse(); diff --git a/Maple2.Server.World/Service/WorldService.cs b/Maple2.Server.World/Service/WorldService.cs index 32b73cb0f..8fac82cf3 100644 --- a/Maple2.Server.World/Service/WorldService.cs +++ b/Maple2.Server.World/Service/WorldService.cs @@ -16,6 +16,7 @@ public partial class WorldService : World.WorldBase { private readonly GroupChatLookup groupChatLookup; private readonly BlackMarketLookup blackMarketLookup; private readonly GlobalPortalLookup globalPortalLookup; + private readonly WorldBossLookup worldBossLookup; private readonly PlayerConfigLookUp playerConfigLookUp; private readonly ILogger logger = Log.Logger.ForContext(); @@ -23,7 +24,7 @@ public WorldService( IMemoryCache tokenCache, WorldServer worldServer, ChannelClientLookup channelClients, PlayerInfoLookup playerLookup, GuildLookup guildLookup, PartyLookup partyLookup, PartySearchLookup partySearchLookup, GroupChatLookup groupChatLookup, BlackMarketLookup blackMarketLookup, - ClubLookup clubLookup, GlobalPortalLookup globalPortalLookup, PlayerConfigLookUp playerConfigLookUp + ClubLookup clubLookup, GlobalPortalLookup globalPortalLookup, WorldBossLookup worldBossLookup, PlayerConfigLookUp playerConfigLookUp ) { this.tokenCache = tokenCache; this.worldServer = worldServer; @@ -36,6 +37,7 @@ public WorldService( this.clubLookup = clubLookup; this.blackMarketLookup = blackMarketLookup; this.globalPortalLookup = globalPortalLookup; + this.worldBossLookup = worldBossLookup; this.playerConfigLookUp = playerConfigLookUp; } diff --git a/Maple2.Server.World/WorldServer.cs b/Maple2.Server.World/WorldServer.cs index a8046de75..b8996ad27 100644 --- a/Maple2.Server.World/WorldServer.cs +++ b/Maple2.Server.World/WorldServer.cs @@ -23,6 +23,7 @@ public class WorldServer { private readonly ServerTableMetadataStorage serverTableMetadata; private readonly ItemMetadataStorage itemMetadata; private readonly GlobalPortalLookup globalPortalLookup; + private readonly WorldBossLookup worldBossLookup; private readonly PlayerInfoLookup playerInfoLookup; private readonly Thread thread; private readonly Thread heartbeatThread; @@ -35,11 +36,12 @@ public class WorldServer { private readonly LoginClient login; - public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, ServerTableMetadataStorage serverTableMetadata, GlobalPortalLookup globalPortalLookup, PlayerInfoLookup playerInfoLookup, LoginClient login, ItemMetadataStorage itemMetadata) { + public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, ServerTableMetadataStorage serverTableMetadata, GlobalPortalLookup globalPortalLookup, WorldBossLookup worldBossLookup, PlayerInfoLookup playerInfoLookup, LoginClient login, ItemMetadataStorage itemMetadata) { this.gameStorage = gameStorage; this.channelClients = channelClients; this.serverTableMetadata = serverTableMetadata; this.globalPortalLookup = globalPortalLookup; + this.worldBossLookup = worldBossLookup; this.playerInfoLookup = playerInfoLookup; this.login = login; this.itemMetadata = itemMetadata; @@ -56,6 +58,7 @@ public WorldServer(GameStorage gameStorage, ChannelClientLookup channelClients, StartWeeklyReset(); StartMonthlyReset(); StartWorldEvents(); + StartWorldBossEvents(); ScheduleGameEvents(); FieldPlotExpiryCheck(); thread = new Thread(Loop); @@ -306,6 +309,98 @@ private void GlobalPortal(GlobalPortalMetadata data, DateTime startTime) { scheduler.Schedule(() => GlobalPortal(data, nextRunTime), nextRunTime - DateTime.Now); } + private void StartWorldBossEvents() { + int scheduled = 0; + foreach ((int _, WorldBossMetadata boss) in serverTableMetadata.TimeEventTable.WorldBoss) { + if (boss.EndTime < DateTime.Now) { + logger.Debug("WorldBoss {Id} skipped — event period ended ({EndTime})", boss.Id, boss.EndTime); + continue; + } + if (boss.CycleTime == TimeSpan.Zero) { + logger.Debug("WorldBoss {Id} skipped — no cycle time", boss.Id); + continue; + } + + DateTime startTime = boss.StartTime; + if (DateTime.Now > startTime) { + // Catch up to the next scheduled spawn after now + while (startTime < DateTime.Now) { + startTime += boss.CycleTime; + } + if (startTime > boss.EndTime) { + logger.Debug("WorldBoss {Id} skipped — next spawn {NextSpawn} is past EndTime {EndTime}", boss.Id, startTime, boss.EndTime); + continue; + } + } + + TimeSpan delay = startTime - DateTime.Now; + logger.Debug("WorldBoss {Id} (NpcId: {NpcId}) scheduled — first spawn in {Delay:hh\\:mm\\:ss} at {SpawnTime:HH:mm:ss} UTC", + boss.Id, boss.NpcIds.Length > 0 ? boss.NpcIds[0] : 0, delay, startTime.ToUniversalTime()); + scheduler.Schedule(() => SpawnWorldBoss(boss, startTime), delay); + scheduled++; + } + logger.Information("WorldBoss scheduler started — {Count} bosses scheduled", scheduled); + } + + private void SpawnWorldBoss(WorldBossMetadata metadata, DateTime spawnTime) { + bool shouldSpawn = !(metadata.Probability < 100 && Random.Shared.Next(100) >= metadata.Probability); + + // Compute next spawn time before Create() so it can be broadcast with the announce + DateTime nextSpawn = spawnTime + metadata.CycleTime; + if (metadata.RandomTime > TimeSpan.Zero) { + nextSpawn += TimeSpan.FromMilliseconds(Random.Shared.Next((int) metadata.RandomTime.TotalMilliseconds)); + } + long nextSpawnTimestamp = nextSpawn <= metadata.EndTime ? new DateTimeOffset(nextSpawn).ToUnixTimeSeconds() : 0; + + if (!shouldSpawn) { + logger.Debug("WorldBoss {Id} roll failed (probability {Probability}%) — skipping spawn, next at {NextSpawn:HH:mm:ss} UTC", + metadata.Id, metadata.Probability, nextSpawn.ToUniversalTime()); + } else if (worldBossLookup.TryGet(metadata.Id, out _)) { + logger.Debug("WorldBoss {Id} still active from previous cycle — skipping spawn", metadata.Id); + } else { + long spawnTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + long endTick = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + (long) metadata.LifeTime.TotalMilliseconds; + if (worldBossLookup.Create(metadata, endTick, nextSpawnTimestamp, out int eventId)) { + if (worldBossLookup.TryGet(metadata.Id, out WorldBossManager? manager)) { + manager.Boss.SpawnTimestamp = spawnTimestamp; + manager.Announce(); + } + logger.Information("WorldBoss {Id} (NpcId: {NpcId}) spawned — eventId: {EventId}, alive for {LifeTime:mm\\:ss}, next spawn at {NextSpawn:HH:mm:ss} UTC", + metadata.Id, metadata.NpcIds.Length > 0 ? metadata.NpcIds[0] : 0, eventId, metadata.LifeTime, nextSpawn.ToUniversalTime()); + + TimeSpan warnDelay = metadata.LifeTime - TimeSpan.FromMinutes(1); + _ = MonitorWorldBossLifetimeAsync(metadata.Id, warnDelay, metadata.LifeTime, tokenSource.Token); + } + } + + if (nextSpawn > metadata.EndTime) { + logger.Information("WorldBoss {Id} will not respawn — next spawn {NextSpawn} is past EndTime {EndTime}", metadata.Id, nextSpawn, metadata.EndTime); + return; + } + + scheduler.Schedule(() => SpawnWorldBoss(metadata, nextSpawn), nextSpawn - DateTime.Now); + } + + private async Task MonitorWorldBossLifetimeAsync(int metadataId, TimeSpan warnDelay, TimeSpan lifeTime, CancellationToken token) { + try { + if (warnDelay > TimeSpan.Zero) { + await Task.Delay(warnDelay, token); + if (worldBossLookup.TryGet(metadataId, out WorldBossManager? warnManager)) { + warnManager.WarnChannels(); + } + await Task.Delay(TimeSpan.FromMinutes(1), token); + } else { + await Task.Delay(lifeTime, token); + } + worldBossLookup.Dispose(metadataId); + logger.Information("WorldBoss {Id} lifetime expired — despawning", metadataId); + } catch (OperationCanceledException) { + // Server shutting down — do not despawn or log as error + } catch (Exception ex) { + logger.Error(ex, "Error monitoring field boss lifetime for metadata {MetadataId}", metadataId); + } + } + private void ScheduleGameEvents() { IEnumerable events = serverTableMetadata.GetGameEvents().ToList(); // Add Events