From f135e908993e7aa0da38c400ba17f42b500a9749 Mon Sep 17 00:00:00 2001 From: Acentech Dev Date: Wed, 15 Jul 2026 16:22:13 +0300 Subject: [PATCH] fix(CS): stop crown re-capture loop + show seal panel only to the master - The occupier can no longer re-register its own throne (canCapture = eligible != occupier), which caused a repeating 60s panel loop; only a DIFFERENT guild can contest. - The 60s registration panel (0xB2/0x15) is now sent ONLY to the master on the crown, not broadcast to switch-holders/other players. Shield(0x16) + capture(0x18) still broadcast. +1 test (15 total). --- .../CastleSiege/CastleSiegeContext.cs | 6 +- .../PeriodicTasks/CastleSiegeEventPlugIn.cs | 71 ++++++++----------- .../CastleSiege/CastleSiegeContextTest.cs | 29 ++++++++ 3 files changed, 64 insertions(+), 42 deletions(-) diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs index d9d985d..77361eb 100644 --- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs +++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs @@ -231,7 +231,11 @@ public class CastleSiegeContext var wasHolding = this._crownHoldGuild is not null; - if (eligibleGuild is null || !masterHolding) + // The guild that already occupies the throne just holds it — no re-registration (avoids a capture loop). + // Only a DIFFERENT guild taking both switches can register/capture (contest). + var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier; + + if (!canCapture || !masterHolding) { this._crownHoldGuild = null; this._crownHoldStartUtc = null; diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs index 9e15915..54823dc 100644 --- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs +++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs @@ -359,7 +359,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom // 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(); - var masterOnCrown = false; + Player? masterPlayer = null; if (eligible is not null) { foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType()) @@ -367,18 +367,35 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom if (player.GuildStatus?.Position == GuildPosition.GuildMaster && await GetGuildNameAsync(player).ConfigureAwait(false) == eligible) { - masterOnCrown = true; + masterPlayer = player; break; } } } - var holdDuration = context.Configuration.CrownHoldDuration; - var crown = context.TickCrownHold(eligible, masterOnCrown, now, holdDuration); - await BroadcastCrownAsync(gameContext, crown).ConfigureAwait(false); - if (crown.Event == CrownEvent.Captured && crown.Guild is { } capturedGuild) + 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 AnnounceAsync(gameContext, $"Guild '{capturedGuild}' has taken the Crown and now holds the throne!").ConfigureAwait(false); + 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 @@ -416,40 +433,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom } }); - /// - /// Sends the crown shield state (each tick) and any crown-hold event (start/reset/capture) to every player - /// currently on the battle map. - /// - private static ValueTask BroadcastCrownAsync(IGameContext gameContext, CrownTickResult crown) - => gameContext.ForEachPlayerAsync(async player => - { - if (player.CurrentMap?.Definition.Number != ValleyOfLorenMapNumber) - { - return; - } - - // Only tell the client when the shield state actually flips — the 0xB2/0x16 packet pops a modal - // message box, so sending it every tick would block player input. - if (crown.ShieldChanged) - { - await player.InvokeViewPlugInAsync(p => p.SetCrownShieldAsync(crown.ShieldDown)).ConfigureAwait(false); - } - - switch (crown.Event) - { - case CrownEvent.HoldStarted: - await player.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false); - break; - case CrownEvent.HoldReset: - await player.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false); - break; - case CrownEvent.Captured when crown.Guild is { } guild: - await player.InvokeViewPlugInAsync(p => p.AnnounceSealCapturedAsync(guild)).ConfigureAwait(false); - break; - default: - break; - } - }); + /// 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) { diff --git a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs index ed9b4f3..66ef0ec 100644 --- a/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs +++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs @@ -236,6 +236,35 @@ public class CastleSiegeContextTest Assert.That(ctx.OccupierGuildName, Is.Null); } + /// Tests that the occupier can't re-capture its own throne, but a different guild can contest it. + [Test] + public async Task OccupierDoesNotRecaptureButAnotherGuildCanContestAsync() + { + var ctx = new CastleSiegeContext(Config()); + await ctx.ForceStartRegistrationAsync(T0); + await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); + ctx.SetDefenseCount(0); + var hold = TimeSpan.FromSeconds(60); + + ctx.SetSwitchHolder(217, "A"); + ctx.SetSwitchHolder(218, "A"); + ctx.TickCrownHold("A", true, T0, hold); + Assert.That(ctx.TickCrownHold("A", true, T0.AddSeconds(60), hold).Event, Is.EqualTo(CrownEvent.Captured)); + Assert.That(ctx.OccupierGuildName, Is.EqualTo("A")); + + // A keeps holding — no re-registration loop, but the shield stays down (they hold it). + var after = ctx.TickCrownHold("A", true, T0.AddSeconds(61), hold); + Assert.That(after.Event, Is.EqualTo(CrownEvent.None)); + Assert.That(after.ShieldDown, Is.True); + + // B takes both switches and can contest/capture. + ctx.SetSwitchHolder(217, "B"); + ctx.SetSwitchHolder(218, "B"); + Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(62), hold).Event, Is.EqualTo(CrownEvent.HoldStarted)); + Assert.That(ctx.TickCrownHold("B", true, T0.AddSeconds(122), hold).Event, Is.EqualTo(CrownEvent.Captured)); + Assert.That(ctx.OccupierGuildName, Is.EqualTo("B")); + } + private static CastleSiegeConfiguration Config() => new() { RegistrationDuration = TimeSpan.FromMinutes(5),