From 72ebc96e28a82d130d8c2476650a4bcdccfce04a Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Wed, 15 Jul 2026 02:49:27 +0300 Subject: [PATCH] feat(CS-P3): throne registration via Sinior/Crown talk (223/216) after defenses down + both switches held; reachable defense positions --- .../CastleSiege/CastleSiegeContext.cs | 36 +++++--- .../CastleSiegeThroneCaptureTalkPlugIn.cs | 83 +++++++++++++++++++ .../PeriodicTasks/CastleSiegeEventPlugIn.cs | 16 ++-- .../CastleSiege/CastleSiegeContextTest.cs | 17 ++-- 4 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index 2e581ad..23e776e 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -169,26 +169,38 @@ public class CastleSiegeContext } /// - /// Evaluates the throne capture: if the siege is running, all defenses are destroyed, the throne is not - /// yet taken, and one guild holds BOTH Crown Switches at once, that guild captures the throne. + /// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC). + /// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the + /// guild is currently holding BOTH Crown Switches (a member standing on each). /// - /// The guild that just captured the throne (for announcing), or null if nothing changed. - public string? EvaluateCapture() + /// The capturing guild's name. + /// Whether it succeeded and a human-readable reason/result message. + public (bool Success, string Reason) TryCaptureThrone(string guildName) { - if (this.Phase != CastleSiegePhase.Siege || this._occupier is not null || this._defensesRemaining > 0) + if (this.Phase != CastleSiegePhase.Siege) { - return null; + return (false, "The siege is not running."); } - var holder217 = this._switchHolders[217]; - var holder218 = this._switchHolders[218]; - if (holder217 is not null && holder217 == holder218) + if (this._occupier is not null) { - this._occupier = holder217; - return holder217; + return (false, this._occupier == guildName + ? "Your guild already holds the throne." + : $"The throne is already held by '{this._occupier}'."); } - return null; + if (this._defensesRemaining > 0) + { + return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining)."); + } + + if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName) + { + return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each)."); + } + + this._occupier = guildName; + return (true, "throne captured"); } /// Returns a human-readable status summary for admin display. diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs new file mode 100644 index 0000000..2312bac --- /dev/null +++ b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs @@ -0,0 +1,83 @@ +// +// 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 registering on the throne at the Sinior (223) / Crown (216) NPCs on Valley of Loren. +/// A registered guild takes the throne only when it has destroyed all castle defenses (gates + statues) +/// AND is holding both Crown Switches (a member standing on each). The guild on the throne when the siege +/// ends becomes the castle owner. Capturing broadcasts a server-wide golden message. +/// +[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000223")] +[PlugIn] +[Display(Name = "Castle Siege Throne (Sinior/Crown)", Description = "Registers a guild on the throne (Sinior 223 / Crown 216) once defenses are down and both switches held.")] +public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn +{ + private static readonly short[] ThroneNpcNumbers = { 223, 216 }; + + /// + 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); + if (success) + { + await player.GameContext.ForEachPlayerAsync(p => + p.InvokeViewPlugInAsync(v => + v.ShowMessageAsync($"Guild '{guildName}' has CAPTURED THE THRONE! They will win the castle if they hold it until the siege ends.", MessageType.GoldenCenter)).AsTask()).ConfigureAwait(false); + } + else + { + await ShowAsync(player, reason).ConfigureAwait(false); + } + } + + private static ValueTask ShowAsync(Player player, string text) + => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal)); +} diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index e0df12b..746fc8b 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -30,12 +30,14 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom private const int SwitchHoldRange = 3; // Castle defenses spawned at siege start — destructibles the attackers must break: (number, x, y). + // Positions are in the throne-room area (near the crown ~176,212 and the switches ~167-184,194-195), + // where players can actually reach and hit them (the earlier statue spot 170/182,206 was confirmed reachable). private static readonly (short Number, byte X, byte Y)[] DefenseSpawns = { - (CastleGateNumber, 160, 180), - (CastleGateNumber, 190, 180), - (GuardianStatueNumber, 170, 206), - (GuardianStatueNumber, 182, 206), + (CastleGateNumber, 168, 200), + (CastleGateNumber, 184, 200), + (GuardianStatueNumber, 170, 208), + (GuardianStatueNumber, 182, 208), }; // Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y). @@ -209,12 +211,6 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom context.SetSwitchHolder(switchNumber, holder); } - - var captured = context.EvaluateCapture(); - if (captured is not null) - { - await AnnounceAsync(gameContext, $"Guild '{captured}' has CAPTURED THE THRONE! They win the castle if they hold it until the siege ends.").ConfigureAwait(false); - } } catch (Exception ex) { diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index 0fb0179..bb3cd06 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -79,18 +79,18 @@ public class CastleSiegeContextTest // Both switches held but defenses still up -> no capture. ctx.SetSwitchHolder(217, "Attackers"); ctx.SetSwitchHolder(218, "Attackers"); - Assert.That(ctx.EvaluateCapture(), Is.Null); + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); ctx.NotifyDefenseDestroyed(); ctx.NotifyDefenseDestroyed(); // Only one switch held -> still no capture. ctx.SetSwitchHolder(218, null); - Assert.That(ctx.EvaluateCapture(), Is.Null); + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); - // Both switches held by the same guild + defenses down -> capture. + // Both switches held by the same guild + defenses down -> capture at the Sinior/Crown. ctx.SetSwitchHolder(218, "Attackers"); - Assert.That(ctx.EvaluateCapture(), Is.EqualTo("Attackers")); + Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement @@ -108,17 +108,18 @@ public class CastleSiegeContextTest ctx.SetDefenseCount(0); ctx.SetSwitchHolder(217, "A"); ctx.SetSwitchHolder(218, "B"); - Assert.That(ctx.EvaluateCapture(), Is.Null); + Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); + Assert.That(ctx.TryCaptureThrone("B").Success, Is.False); } - /// Tests that switch holding outside the siege phase is a no-op. + /// Tests that capturing the throne outside the siege phase is a no-op. [Test] - public void SwitchHoldOutsideSiegeIsNoOp() + public void ThroneCaptureOutsideSiegeIsNoOp() { var ctx = new CastleSiegeContext(Config()); ctx.SetSwitchHolder(217, "A"); ctx.SetSwitchHolder(218, "A"); - Assert.That(ctx.EvaluateCapture(), Is.Null); + Assert.That(ctx.TryCaptureThrone("A").Success, Is.False); Assert.That(ctx.OccupierGuildName, Is.Null); }