diff --git a/docs/superpowers/specs/2026-07-14-castle-siege-design.md b/docs/superpowers/specs/2026-07-14-castle-siege-design.md index 67f3dc0..7c476da 100644 --- a/docs/superpowers/specs/2026-07-14-castle-siege-design.md +++ b/docs/superpowers/specs/2026-07-14-castle-siege-design.md @@ -167,6 +167,7 @@ docker push /adamu-openmu: | `GameLogic/GameMap.cs` | 0b | remote-NPC: `GetNpcByNumber` helper (additive metod) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli; test: `GameMapTest.GetNpcByNumberFindsSpawnedNpcAsync` | | `GameServer/MessageHandler/TalkNpcHandlerPlugInBase.cs` | 0b | remote-NPC: `0x8000` marker dalı (tek gerçek core-logic dokunuşu) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli | | `GameLogic/Player.cs` | P3 | CS PvP: Siege fazında Valley of Loren'de (map 30) kill izni (`IsCastleSiegeBattleActive`) | ✅ Uygulandı, `// ADAMU-CUSTOM` | +| `GameLogic/GameContext.cs` | P3 | `MapInitializer` property expose (guardian heykel Destructible runtime spawn) | ✅ Uygulandı, `// ADAMU-CUSTOM` | | DataModel + Initialization (CS entity'leri) | P4 | CS persistence (sahiplik/vergi) | ⏳ Planlanacak (EF migration gerekir) | | *(fazlar ilerledikçe eklenecek)* | | | | diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 54026db..8441b79 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -11,8 +11,10 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege; public class CastleSiegeContext { private readonly List _registeredGuilds = new(); + private readonly Dictionary _switchHolders = new(); private DateTime _phaseStartedUtc; private string? _occupier; + private int _statuesRemaining; /// Initializes a new instance of the class. /// The cycle timing configuration. @@ -95,6 +97,8 @@ public class CastleSiegeContext public ValueTask ForceStartRegistrationAsync(DateTime now) { this._registeredGuilds.Clear(); + this._switchHolders.Clear(); + this._statuesRemaining = 0; this._occupier = null; return this.TransitionAsync(CastleSiegePhase.Registration, now); } @@ -110,6 +114,8 @@ public class CastleSiegeContext public ValueTask ResetAsync(DateTime now) { this._registeredGuilds.Clear(); + this._switchHolders.Clear(); + this._statuesRemaining = 0; this._occupier = null; return this.TransitionAsync(CastleSiegePhase.Ownership, now); } @@ -129,16 +135,65 @@ public class CastleSiegeContext /// The owner guild name, or null to clear. public void SetOwner(string? guildName) => this.OwnerGuildName = guildName; - /// P3: a registered guild captures the throne during the siege. No-op outside the siege phase. - /// The capturing guild's name. - public void CaptureThrone(string guildName) + /// Gets the number of guardian statues still standing (must be 0 before the throne can be taken). + public int StatuesRemaining => this._statuesRemaining; + + /// Sets how many guardian statues defend the castle this siege (called when they are spawned). + /// The statue count. + public void SetStatueCount(int count) => this._statuesRemaining = count > 0 ? count : 0; + + /// Notifies that a guardian statue was destroyed. Lowers the remaining count (min 0). + public void NotifyStatueDestroyed() + { + if (this._statuesRemaining > 0) + { + this._statuesRemaining--; + } + } + + /// + /// P3: a registered guild member activates one of the two Crown Switches (217/218) during the siege. + /// The throne can only be taken by a guild holding BOTH switches. No-op outside the siege phase. + /// + /// The Crown Switch NPC number (217 or 218). + /// The activating guild's name. + public void HoldSwitch(short switchNumber, string guildName) { if (this.Phase == CastleSiegePhase.Siege) { - this._occupier = guildName; + this._switchHolders[switchNumber] = guildName; } } + /// + /// P3: attempts to capture the throne for a guild. Succeeds only during the siege when all guardian + /// statues are destroyed and the guild holds both Crown Switches. + /// + /// The capturing guild's name. + /// A tuple of success and a human-readable reason/result message. + public (bool Success, string Reason) TryCaptureThrone(string guildName) + { + if (this.Phase != CastleSiegePhase.Siege) + { + return (false, "The siege is not running."); + } + + if (this._statuesRemaining > 0) + { + return (false, $"Destroy the guardian statues first ({this._statuesRemaining} remaining)."); + } + + var holdsBoth = this._switchHolders.TryGetValue(217, out var s217) && s217 == guildName + && this._switchHolders.TryGetValue(218, out var s218) && s218 == guildName; + if (!holdsBoth) + { + return (false, "Your guild must hold both Crown Switches (217 and 218) at once."); + } + + this._occupier = guildName; + return (true, "throne captured"); + } + /// Returns a human-readable status summary for admin display. public string GetStatusText() => $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, " diff --git a/src/GameLogic/CastleSiege/CastleSiegeCrownTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeCrownTalkPlugIn.cs new file mode 100644 index 0000000..a5f17c2 --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeCrownTalkPlugIn.cs @@ -0,0 +1,77 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.CastleSiege; + +using System.Linq; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.NPC; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// +/// Handles talking to the Castle Siege throne NPCs — Crown (216) and Sinior (223) — on Valley of Loren. +/// A registered guild takes the throne only when it has destroyed all guardian statues AND holds both +/// Crown Switches (217/218). The guild on the throne when the siege ends becomes the castle owner. +/// +[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000216")] +[PlugIn] +[Display(Name = "Castle Siege Crown/Throne", Description = "Captures the throne (Crown 216 / Sinior 223) once statues are down and both switches held.")] +public class CastleSiegeCrownTalkPlugIn : IPlayerTalkToNpcPlugIn +{ + private static readonly short[] ThroneNpcNumbers = { 216, 223 }; + + /// + public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs) + { + if (!ThroneNpcNumbers.Contains(npc.Definition.Number)) + { + return; + } + + eventArgs.HasBeenHandled = true; + + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false); + return; + } + + if (player.GuildStatus is not { } guildStatus) + { + await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false); + return; + } + + var guildName = guildStatus.GuildId.ToString(); + if (player.GameContext is IGameServerContext serverContext) + { + var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false); + if (guild?.Name is { Length: > 0 } name) + { + guildName = name; + } + } + + if (!context.RegisteredGuilds.Contains(guildName)) + { + await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false); + return; + } + + var (success, reason) = context.TryCaptureThrone(guildName); + await ShowAsync( + player, + success + ? $"Your guild '{guildName}' has CAPTURED THE THRONE! Hold it until the siege ends to win the castle!" + : reason).ConfigureAwait(false); + } + + private static ValueTask ShowAsync(Player player, string text) + => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal)); +} diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs index ac61655..59c361f 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs @@ -70,8 +70,8 @@ public class CastleSiegeThroneTalkPlugIn : IPlayerTalkToNpcPlugIn return; } - context.CaptureThrone(guildName); - await ShowAsync(player, $"Your guild '{guildName}' has captured the throne! Hold it until the siege ends to win the castle.").ConfigureAwait(false); + context.HoldSwitch(npc.Definition.Number, guildName); + await ShowAsync(player, $"Your guild '{guildName}' is holding this Crown Switch. Hold BOTH switches (217 and 218) and destroy the guardian statues, then take the throne!").ConfigureAwait(false); } private static ValueTask ShowAsync(Player player, string text) diff --git a/src/GameLogic/GameContext.cs b/src/GameLogic/GameContext.cs index a15327f..00ba1fb 100644 --- a/src/GameLogic/GameContext.cs +++ b/src/GameLogic/GameContext.cs @@ -116,6 +116,10 @@ public class GameContext : AsyncDisposable, IGameContext /// public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate; + // ADAMU-CUSTOM: expose the map initializer so the Castle Siege can spawn guardian statues (Destructibles) at runtime. + /// Gets the map initializer (used to spawn NPCs/destructibles at runtime, e.g. Castle Siege statues). + public IMapInitializer MapInitializer => this._mapInitializer; + /// public virtual bool PvpEnabled { get; } diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 4763452..8915b28 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -8,6 +8,7 @@ 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.Interfaces; using MUnique.OpenMU.PlugIns; @@ -23,6 +24,8 @@ using MUnique.OpenMU.PlugIns; public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration { private const ushort ValleyOfLorenMapNumber = 30; + private const short GuardianStatueNumber = 132; // reuse the "Statue of Saint" destructible definition + private static readonly (byte X, byte Y)[] StatuePositions = { (170, 206), (182, 206) }; private static readonly ConcurrentDictionary Contexts = new(); @@ -76,7 +79,8 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom 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! Fight your way to the throne and capture it to win the castle!").ConfigureAwait(false); + await AnnounceAsync(gameContext, "The Castle Siege has begun! Destroy the guardian statues, hold both Crown Switches, then take the throne!").ConfigureAwait(false); + await SpawnGuardianStatuesAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); break; case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner: @@ -97,6 +101,58 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom => gameContext.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask()); + private static async Task SpawnGuardianStatuesAsync(IGameContext gameContext, CastleSiegeContext context) + { + try + { + if (gameContext is not GameContext concrete) + { + context.SetStatueCount(0); + return; + } + + var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false); + var statueDef = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardianStatueNumber); + if (map is null || statueDef is null) + { + context.SetStatueCount(0); + return; + } + + var spawned = 0; + for (var i = 0; i < StatuePositions.Length; i++) + { + var pos = StatuePositions[i]; + var spawnArea = new MonsterSpawnArea + { + MonsterDefinition = statueDef, + Quantity = 1, + X1 = pos.X, + X2 = pos.X, + Y1 = pos.Y, + Y2 = pos.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.NotifyStatueDestroyed(); + spawned++; + } + } + + context.SetStatueCount(spawned); + } + catch (Exception ex) + { + gameContext.LoggerFactory.CreateLogger() + .LogError(ex, "Castle Siege: error while spawning guardian statues."); + context.SetStatueCount(0); + } + } + private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) { try diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index 7e05c7d..1035379 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -67,27 +67,53 @@ public class CastleSiegeContextTest Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers")); } - /// Tests that the throne-holding guild at siege end becomes the owner on settlement (P3). + /// Tests the full siege objective chain: destroy statues, hold both switches, then capture the throne. [Test] - public async Task ThroneCaptureDuringSiegeBecomesOwnerOnSettlementAsync() + public async Task FullSiegeObjectiveChainToOwnershipAsync() { var ctx = new CastleSiegeContext(Config()); await ctx.ForceStartRegistrationAsync(T0); await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); - ctx.CaptureThrone("Attackers"); + ctx.SetStatueCount(2); + + // Throne blocked until statues destroyed + both switches held. + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); + + ctx.NotifyStatueDestroyed(); + ctx.NotifyStatueDestroyed(); + ctx.HoldSwitch(217, "Attackers"); + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); // only one switch held + + ctx.HoldSwitch(218, "Attackers"); + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); // both switches + statues down Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); + await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement - await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership (owner assigned) - Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); + await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); } - /// Tests that capturing the throne outside the siege phase is a no-op. + /// Tests that a rival guild holding only one switch cannot steal the throne. [Test] - public void CaptureThroneOutsideSiegeIsNoOp() + public async Task ThroneRequiresBothSwitchesBySameGuildAsync() { var ctx = new CastleSiegeContext(Config()); - ctx.CaptureThrone("Attackers"); + await ctx.ForceStartRegistrationAsync(T0); + await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); + ctx.SetStatueCount(0); + ctx.HoldSwitch(217, "A"); + ctx.HoldSwitch(218, "B"); + Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); + Assert.That(ctx.TryCaptureThrone("B").Success, Is.False); + } + + /// Tests that objective actions outside the siege phase are no-ops. + [Test] + public void ObjectiveActionsOutsideSiegeAreNoOp() + { + var ctx = new CastleSiegeContext(Config()); + ctx.HoldSwitch(217, "A"); + Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(ctx.OccupierGuildName, Is.Null); }