// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; using System.Collections.Concurrent; using System.Linq; using System.Runtime.InteropServices; using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.NPC; using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views.CastleSiege; using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Pathfinding; using MUnique.OpenMU.Persistence; using MUnique.OpenMU.PlugIns; /// /// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration. /// State is per- and kept in memory (P1: no persistence). /// When the siege phase starts (P3), warps registered guild members to the Valley of Loren battle map. /// [PlugIn] [Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (phase state machine, scheduling, siege warp).")] [Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")] public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration { private const ushort ValleyOfLorenMapNumber = 30; private const short CastleGateNumber = 277; // client MONSTER_CASTLE_GATE1 -> renders a real gate + blocks terrain until broken private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for 277 private const int SwitchHoldRange = 3; // Castle defenses spawned at siege start — the 6 REAL castle gates (client g_byGateLocation). // The client renders them closed and blocks the terrain until each is broken. All must fall before the throne. private static readonly (short Number, byte X, byte Y)[] DefenseSpawns = { (CastleGateNumber, 67, 114), (CastleGateNumber, 93, 114), (CastleGateNumber, 119, 114), (CastleGateNumber, 81, 161), (CastleGateNumber, 107, 161), (CastleGateNumber, 93, 204), }; // Siege weapons spawned at siege start purely for war atmosphere. The client renders monster 221/222 as // catapults (attacker/defender). They are NOT counted as defenses (destroying them doesn't open the throne). private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE private static readonly (short Number, byte X, byte Y)[] CatapultSpawns = { (CatapultDefenseNumber, 80, 140), (CatapultDefenseNumber, 110, 140), (CatapultDefenseNumber, 93, 178), (CatapultAttackNumber, 74, 100), (CatapultAttackNumber, 112, 100), }; // Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y). private static readonly (short SwitchNumber, byte X, byte Y)[] SwitchPositions = { (217, 167, 194), (218, 184, 195), }; // The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne. private static readonly Point CrownPosition = new(176, 212); private const int CrownHoldRange = 4; private static readonly ConcurrentDictionary Contexts = new(); private string? _cachedFlagOwner; private byte[]? _cachedFlagLogo; /// public CastleSiegeConfiguration? Configuration { get; set; } /// /// Gets the Castle Siege context for a game context, if the periodic tick has initialized it. /// /// The game context. /// The context, or null if not yet initialized. public static CastleSiegeContext? TryGetContext(IGameContext gameContext) => Contexts.TryGetValue(gameContext, out var context) ? context : null; /// public object CreateDefaultConfig() => new CastleSiegeConfiguration(); /// public async ValueTask ExecuteTaskAsync(GameContext gameContext) { var context = Contexts.GetOrAdd(gameContext, gc => { var config = this.Configuration ?? new CastleSiegeConfiguration(); var created = new CastleSiegeContext(config); // Restore state persisted before the last restart (owner/phase/registrations) BEFORE subscribing, // so restoring doesn't announce phases or re-spawn defenses. created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds); // Announce phase changes to the whole server and, when the siege begins, warp registered members. created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase); return created; }); // Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live. if (this.Configuration is { } liveConfig) { context.UpdateConfiguration(liveConfig); } await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false); // During the siege, evaluate the Crown Switches (held by standing on them) and the throne capture every tick. if (context.Phase == CastleSiegePhase.Siege) { await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false); } // Persist owner/phase/registrations to the database whenever they changed, so they survive a restart. if (context.ConsumeDirty()) { await this.PersistStateAsync(gameContext, context).ConfigureAwait(false); } // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any phase). if (DateTime.UtcNow.Second % 15 == 0) { await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false); } } /// public void ForceStart() { // Force-start applies to every active game context's siege. foreach (var context in Contexts.Values) { _ = context.ForceStartRegistrationAsync(DateTime.UtcNow); } } private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase) { try { switch (phase) { case CastleSiegePhase.Registration: await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false); break; case CastleSiegePhase.Siege: await AnnounceAsync(gameContext, "The Castle Siege has begun! Break the castle gates and guardian statues, then hold BOTH Crown Switches to take the throne!").ConfigureAwait(false); await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); break; case CastleSiegePhase.Settlement: // Stop the on-map countdown for everyone still on the battle map. await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false); break; case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner: await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false); break; default: break; } } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error handling phase change to {phase}.", phase); } } private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context) { try { if (this.Configuration is not { } config) { return; } // Snapshot the live context into the persisted config fields. config.PersistedOwnerGuildName = context.OwnerGuildName; config.PersistedPhase = context.Phase; config.PersistedPhaseStartedUtc = context.PhaseStartedUtc; config.PersistedRegisteredGuilds = context.RegisteredGuilds.ToList(); // Find our plugin-configuration row via the in-memory config graph to get its id. var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID; var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId); if (inMemory is null) { return; } // Load a fresh, change-tracked copy of that row in its own (non-caching) context, rewrite its // custom-configuration JSON, and save — this is what actually persists to PostgreSQL. using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration); var row = await ctx.GetByIdAsync(inMemory.GetId()).ConfigureAwait(false); if (row is null) { return; } row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler); await ctx.SaveChangesAsync().ConfigureAwait(false); gameContext.LoggerFactory.CreateLogger() .LogInformation("Castle Siege: persisted state (owner={owner}, phase={phase}).", config.PersistedOwnerGuildName ?? "(none)", config.PersistedPhase); } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error while persisting state to the database."); } } private static ValueTask AnnounceAsync(IGameContext gameContext, string message) => gameContext.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask()); private static void EnsureDestructible(MonsterDefinition target, MonsterDefinition? template) { if (target.ObjectKind == NpcObjectKind.Destructible) { return; } target.ObjectKind = NpcObjectKind.Destructible; if (template is not null && target.Attributes.Count == 0) { foreach (var attribute in template.Attributes) { target.Attributes.Add(attribute); } } } private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context) { try { if (gameContext is not GameContext concrete) { context.SetDefenseCount(0); return; } var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); if (map is null) { context.SetDefenseCount(0); return; } var spawned = 0; for (var i = 0; i < DefenseSpawns.Length; i++) { var spawn = DefenseSpawns[i]; var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number); if (definition is null) { continue; } // The stock CS gate (277) definition has no HP and isn't destructible; make it breakable // by borrowing the HP/attributes of a working destructible (BloodCastle gate 131). var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber); EnsureDestructible(definition, template); var spawnArea = new MonsterSpawnArea { MonsterDefinition = definition, Quantity = 1, X1 = spawn.X, X2 = spawn.X, Y1 = spawn.Y, Y2 = spawn.Y, Direction = Direction.South, SpawnTrigger = SpawnTrigger.OnceAtEventStart, }; var npc = await concrete.MapInitializer.InitializeSpawnAsync(1000 + i, map, spawnArea).ConfigureAwait(false); if (npc is AttackableNpcBase attackable) { attackable.Died += (_, _) => context.NotifyDefenseDestroyed(); spawned++; } } context.SetDefenseCount(spawned); // Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses. for (var i = 0; i < CatapultSpawns.Length; i++) { var spawn = CatapultSpawns[i]; var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number); if (definition is null) { continue; } var spawnArea = new MonsterSpawnArea { MonsterDefinition = definition, Quantity = 1, X1 = spawn.X, X2 = spawn.X, Y1 = spawn.Y, Y2 = spawn.Y, Direction = Direction.South, SpawnTrigger = SpawnTrigger.OnceAtEventStart, }; await concrete.MapInitializer.InitializeSpawnAsync(2000 + i, map, spawnArea).ConfigureAwait(false); } } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error while spawning castle defenses."); context.SetDefenseCount(0); } } private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context) { try { var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); if (map is null) { return; } // Each Crown Switch is held by whichever registered guild currently has a member standing on it. foreach (var (switchNumber, x, y) in SwitchPositions) { string? holder = null; var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType(); foreach (var player in nearby) { var guildName = await GetGuildNameAsync(player).ConfigureAwait(false); if (guildName is not null && context.RegisteredGuilds.Contains(guildName)) { holder = guildName; break; } } context.SetSwitchHolder(switchNumber, holder); } var now = DateTime.UtcNow; // Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops; // that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable). var eligible = context.GetShieldEligibleGuild(); Player? masterPlayer = null; if (eligible is not null) { foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType()) { if (player.GuildStatus?.Position == GuildPosition.GuildMaster && await GetGuildNameAsync(player).ConfigureAwait(false) == eligible) { masterPlayer = player; break; } } } var crown = context.TickCrownHold(eligible, masterPlayer is not null, now, context.Configuration.CrownHoldDuration); // Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal). if (crown.ShieldChanged) { await ForEachOnBattleMapAsync(gameContext, p => p.SetCrownShieldAsync(crown.ShieldDown)).ConfigureAwait(false); } switch (crown.Event) { case CrownEvent.HoldStarted when masterPlayer is not null: // The 60-second registration panel is shown ONLY to the master taking the crown. await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false); break; case CrownEvent.HoldReset when masterPlayer is not null: await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); break; case CrownEvent.Captured when crown.Guild is { } captured: await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false); await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false); break; default: break; } // Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just // loaded the battle map pick it up, without visibly resetting the second-counter too often. if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0) { var remaining = context.GetRemainingSiegeTime(now); var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes); await BroadcastSiegeStateAsync(gameContext, true, (byte)(totalMinutes / 60), (byte)(totalMinutes % 60)).ConfigureAwait(false); } } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error during siege tick processing."); } } /// /// Sends the Castle Siege countdown state to every player currently on the battle map (Valley of Loren). /// When is true it also (re)seeds the remaining hour/minute. /// private static ValueTask BroadcastSiegeStateAsync(IGameContext gameContext, bool started, byte hour, byte minute) => gameContext.ForEachPlayerAsync(async player => { if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber) { return; } await player.InvokeViewPlugInAsync(p => p.SetBattleStateAsync(started)).ConfigureAwait(false); if (started) { await player.InvokeViewPlugInAsync(p => p.SetTimerAsync(hour, minute)).ConfigureAwait(false); } }); /// Invokes the Castle Siege status view for every player currently on the battle map. private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func action) => gameContext.ForEachPlayerAsync(player => player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber ? player.InvokeViewPlugInAsync(action).AsTask() : Task.CompletedTask); private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context) { try { if (context.OwnerGuildName is not { Length: > 0 } owner) { return; } var logo = await this.GetOwnerLogoAsync(gameContext, owner).ConfigureAwait(false); if (logo is null) { return; } await gameContext.ForEachPlayerAsync(async player => { if (player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber) { await player.InvokeViewPlugInAsync(p => p.SetCastleFlagAsync(logo)).ConfigureAwait(false); } }).ConfigureAwait(false); } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error while broadcasting the castle flag."); } } private async ValueTask GetOwnerLogoAsync(IGameContext gameContext, string ownerName) { if (this._cachedFlagOwner == ownerName && this._cachedFlagLogo is not null) { return this._cachedFlagLogo; } if (gameContext is not IGameServerContext serverContext) { return null; } var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false); if (guildId == 0) { return null; } var guild = await serverContext.GuildServer.GetGuildAsync(guildId).ConfigureAwait(false); if (guild?.Logo is not { Length: > 0 } logo) { return null; } this._cachedFlagOwner = ownerName; this._cachedFlagLogo = logo; return logo; } private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) { try { var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); if (map?.SafeZoneSpawnGate is not { } gate) { return; } await gameContext.ForEachPlayerAsync(async player => { var guildName = await GetGuildNameAsync(player).ConfigureAwait(false); if (guildName is not null && context.RegisteredGuilds.Contains(guildName)) { await player.WarpToAsync(gate).ConfigureAwait(false); } }).ConfigureAwait(false); } catch (Exception ex) { gameContext.LoggerFactory.CreateLogger() .LogError(ex, "Castle Siege: error while warping registered members to the battle map."); } } private static async ValueTask GetGuildNameAsync(Player player) { if (player.GuildStatus is not { } guildStatus) { return null; } if (player.GameContext is IGameServerContext serverContext) { var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); if (guild?.Name is { Length: > 0 } name) { return name; } } return guildStatus.GuildId.ToString(); } }