feat(castle-siege): operate the Crown Switches by clicking, capture the crown by holding it
Some checks failed
.NET Core / build (push) Has been cancelled
Some checks failed
.NET Core / build (push) Has been cancelled
The switches used to be held by simply standing near them, and the crown captured by standing near it - clicking a switch only produced the client's "not implemented yet" message. This drives both from the original interaction instead: - Clicking a Crown Switch starts an operation which completes after CastleSiegeSettings.SwitchPushSeconds (15) and keeps the switch for the guild until its operator leaves the switch's area. One player per switch; anybody else clicking it is told another team is on it (C1 B2 14 state 2). - While one guild holds both switches the crown's shield drops for it, and its guild master captures the throne by CLICKING the crown and holding it for CrownHoldTimeSeconds - seeded to 60 now, to match the countdown the client's registration panel hardcodes. The throne stays contestable until the siege ends. - The shield now depends on the switches alone, as in the original; the gates and statues remain what they always were, the obstacle in the way. The switch info packet (C1 B2 20) is broadcast before any switch-state packet because the client's "switch released" handler reads its switch table without checking that it exists - that table is only allocated when the info packet arrives, so the wrong order crashes the client. Also fixed while in here: - The crown registration panel could never be closed: the cancel was only sent while the master still stood on the crown, which is precisely when the hold does NOT break. The panel is now closed for the player it was opened for. - A contested switch was decided by enumeration order. - Panels opened by the siege are closed when it ends. - TryCaptureThrone was dead code carrying a second, diverged rule set. - /csphase advertised the pre-refactor phase names to the client. - The periodic broadcasts keyed off "UtcNow.Second % n", which silently skips when a tick runs late; they count ticks now. The unit tests never compiled against the refactored model - they are migrated to the state machine and guild ids, and cover the new switch and crown rules. A new test proves update 105 writes the configuration into an existing database. ApplyPendingUpdatesTool applies pending configuration updates without the admin panel; it is [Explicit], so it never runs in a normal test pass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns;
|
||||
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
|
||||
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
|
||||
[PlugIn]
|
||||
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Ownership|Registration|Preparation|Siege|Settlement>")]
|
||||
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Idle1|RegisterGuild|Ready|Start|End|EndCycle>")]
|
||||
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
|
||||
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
|
||||
{
|
||||
|
||||
@@ -47,8 +47,27 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
private const int SwitchHoldRange = 3;
|
||||
private const int CrownHoldRange = 4;
|
||||
|
||||
/// <summary>How many ticks (the periodic task runs once per second) between two countdown broadcasts.</summary>
|
||||
private const int SiegeStateBroadcastTicks = 10;
|
||||
|
||||
/// <summary>How many ticks between two castle-flag broadcasts.</summary>
|
||||
private const int CastleFlagBroadcastTicks = 15;
|
||||
|
||||
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
|
||||
|
||||
/// <summary>
|
||||
/// The player whose client currently shows the crown registration panel, per game context. The panel is
|
||||
/// opened for exactly one guild master, and it has to be closed for that same player - by the time the
|
||||
/// hold breaks they are usually no longer on the crown, so they can't be found by position any more.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<IGameContext, Player> CrownHoldPlayers = new();
|
||||
|
||||
/// <summary>
|
||||
/// Tick counters per game context, used to space out the periodic broadcasts. Counting ticks (instead of
|
||||
/// matching a clock second) keeps a broadcast from being skipped when a tick runs late.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<IGameContext, BroadcastCounters> Counters = new();
|
||||
|
||||
/// <summary>
|
||||
/// Maps the in-memory guild id (assigned by the guild server, not stable across restarts) to the guild's
|
||||
/// persistent identifier. Populated lazily; a miss costs one database lookup per guild per process.
|
||||
@@ -156,7 +175,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
// castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere.
|
||||
if (!IsCastleSiegeServer(gameContext))
|
||||
{
|
||||
if (DateTime.UtcNow.Second % 15 == 0)
|
||||
if (GetCounters(gameContext).NextCastleFlag())
|
||||
{
|
||||
await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
|
||||
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
|
||||
@@ -180,7 +199,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
}
|
||||
|
||||
// Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state).
|
||||
if (DateTime.UtcNow.Second % 15 == 0)
|
||||
if (GetCounters(gameContext).NextCastleFlag())
|
||||
{
|
||||
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
|
||||
}
|
||||
@@ -281,8 +300,11 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
|
||||
break;
|
||||
case CastleSiegeState.End:
|
||||
// Stop the on-map countdown for everyone still on the battle map.
|
||||
// Stop the on-map countdown for everyone still on the battle map, and close the panels
|
||||
// the siege opened - the battle state is dropped right after this, so whoever was
|
||||
// operating a switch or holding the crown would keep a dead progress box on screen.
|
||||
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
|
||||
await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false);
|
||||
break;
|
||||
case CastleSiegeState.Idle1 when context.OwnerGuildName is { } owner:
|
||||
await AnnounceAsync(gameContext, $"The Castle Siege has ended. The castle now belongs to the guild '{owner}'!").ConfigureAwait(false);
|
||||
@@ -389,6 +411,50 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
|
||||
private static BroadcastCounters GetCounters(IGameContext gameContext)
|
||||
=> Counters.GetOrAdd(gameContext, _ => new BroadcastCounters());
|
||||
|
||||
/// <summary>
|
||||
/// Closes the client panels the siege opened: the crown registration panel of the master who was holding
|
||||
/// it, and the switch progress box of whoever was operating a switch. Called when the siege ends, before
|
||||
/// the battle state is dropped.
|
||||
/// </summary>
|
||||
private static async Task CloseSiegePanelsAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
|
||||
{
|
||||
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
|
||||
{
|
||||
if (context.GetSwitchOperation(switchNumber) is { } operation)
|
||||
{
|
||||
await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false);
|
||||
await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the switch progress box on the client of the player who was operating it. Object identifiers
|
||||
/// are recycled when a player leaves, so the name is checked too - otherwise a newly connected player
|
||||
/// could inherit the id and get a message box about a switch they never touched.
|
||||
/// </summary>
|
||||
private static async Task CloseSwitchBoxAsync(GameMap map, CastleSiegeSwitchOperation operation)
|
||||
{
|
||||
if (map.GetObject(operation.PlayerId) is Player player && player.Name == operation.PlayerName)
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(
|
||||
p => p.SetCrownSwitchStateAsync(operation.SwitchObjectId, operation.PlayerId, 0)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber)
|
||||
{
|
||||
var npc = GetDefinition(gameContext)?.NpcDefinitions
|
||||
@@ -405,32 +471,45 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
return;
|
||||
}
|
||||
|
||||
// Each Crown Switch is held by whichever registered guild currently has a member standing on it.
|
||||
var now = DateTime.UtcNow;
|
||||
var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds));
|
||||
|
||||
// A Crown Switch belongs to the player who clicked it, for as long as they stay in its area.
|
||||
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
|
||||
{
|
||||
if (GetNpcPosition(gameContext, switchNumber) is not { } position)
|
||||
if (context.GetSwitchOperation(switchNumber) is not { } operation)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
Guid? holder = null;
|
||||
foreach (var player in map.GetAttackablesInRange(position, SwitchHoldRange).OfType<Player>())
|
||||
var stillOnIt = GetNpcPosition(gameContext, switchNumber) is { } position
|
||||
&& map.GetAttackablesInRange(position, SwitchHoldRange)
|
||||
.OfType<Player>()
|
||||
.Any(p => p.Id == operation.PlayerId && p.IsAlive);
|
||||
|
||||
var (switchEvent, affected) = context.TickSwitch(switchNumber, stillOnIt, now, pushDuration);
|
||||
if (affected is null)
|
||||
{
|
||||
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild
|
||||
&& context.IsRegistered(guild.Id))
|
||||
{
|
||||
holder = guild.Id;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
context.SetSwitchHolder(switchNumber, holder);
|
||||
if (switchEvent == CastleSiegeSwitchEvent.Held)
|
||||
{
|
||||
// Repeat the info so the HUD picks up the names (the client only stores them from the
|
||||
// second packet on, because the first one allocates its table).
|
||||
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, affected).ConfigureAwait(false);
|
||||
}
|
||||
else if (switchEvent == CastleSiegeSwitchEvent.Released)
|
||||
{
|
||||
await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, null).ConfigureAwait(false);
|
||||
|
||||
// Close the client's progress box of the player who left, if they are still around.
|
||||
await CloseSwitchBoxAsync(map, affected).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// Crown-hold capture: when a guild holds both switches with every defense down, the crown shield
|
||||
// drops; that guild's master then holds the crown for the configured time to take the throne.
|
||||
// Crown-hold capture: while one guild holds both switches the crown shield drops for it, and its
|
||||
// master captures the throne by clicking the crown and holding it for the configured time.
|
||||
var eligible = context.GetShieldEligibleGuild();
|
||||
Player? masterPlayer = null;
|
||||
string? eligibleName = null;
|
||||
@@ -464,13 +543,25 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
switch (crown.Event)
|
||||
{
|
||||
case CrownEvent.HoldStarted when masterPlayer is not null:
|
||||
// The registration panel is shown ONLY to the master taking the crown.
|
||||
// The registration panel is shown ONLY to the master taking the crown. Remember them:
|
||||
// the hold usually breaks BECAUSE they walked off the crown, and their client still has
|
||||
// the panel open, so the cancel has to reach the player we started it for.
|
||||
CrownHoldPlayers[gameContext] = masterPlayer;
|
||||
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
|
||||
break;
|
||||
case CrownEvent.HoldReset when masterPlayer is not null:
|
||||
await masterPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
|
||||
case CrownEvent.HoldReset:
|
||||
if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
|
||||
{
|
||||
await holdPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
break;
|
||||
case CrownEvent.Captured when crown.GuildName is { } captured:
|
||||
if (CrownHoldPlayers.TryRemove(gameContext, out var capturingPlayer))
|
||||
{
|
||||
await capturingPlayer.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownRegistAsync(1, 0)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -478,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
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.StateStartedUtc).TotalSeconds % 10 == 0)
|
||||
// Keep the client's on-map countdown armed and in sync. Resend every 10 ticks so players who just
|
||||
// loaded the battle map pick it up, without visibly resetting the second-counter too often. This
|
||||
// counts ticks instead of matching a clock second, which a delayed tick would skip silently.
|
||||
if (GetCounters(gameContext).NextSiegeState())
|
||||
{
|
||||
var remaining = context.GetRemainingSiegeTime(now);
|
||||
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
|
||||
@@ -514,6 +606,24 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
});
|
||||
|
||||
/// <summary>Invokes the Castle Siege status view for every player currently on the battle map.</summary>
|
||||
/// <summary>
|
||||
/// Tells everybody on the battle map who is operating a Crown Switch. Besides driving the client's HUD
|
||||
/// list, this is what makes the client allocate its switch table, so it has to be sent before any
|
||||
/// "switch released" packet reaches that client.
|
||||
/// </summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
/// <param name="switchObjectId">The switch NPC's object identifier.</param>
|
||||
/// <param name="operation">The operation, or <see langword="null"/> when the switch became free.</param>
|
||||
internal static ValueTask BroadcastSwitchInfoAsync(IGameContext gameContext, ushort switchObjectId, CastleSiegeSwitchOperation? operation)
|
||||
=> ForEachOnBattleMapAsync(
|
||||
gameContext,
|
||||
p => p.SetCrownSwitchInfoAsync(
|
||||
switchObjectId,
|
||||
operation is null ? (byte)0 : (byte)1,
|
||||
(byte)CastleSiegeJoinSide.Attack1,
|
||||
operation?.GuildName ?? string.Empty,
|
||||
operation?.PlayerName ?? string.Empty));
|
||||
|
||||
private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func<ICastleSiegeStatusViewPlugIn, ValueTask> action)
|
||||
=> gameContext.ForEachPlayerAsync(player =>
|
||||
player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber
|
||||
@@ -693,4 +803,30 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
this._cachedFlagLogo = logo;
|
||||
return logo;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts the ticks between the periodic broadcasts of one game context.
|
||||
/// </summary>
|
||||
private sealed class BroadcastCounters
|
||||
{
|
||||
private int _siegeState;
|
||||
private int _castleFlag;
|
||||
|
||||
/// <summary>Advances the countdown-broadcast counter and tells whether it is due.</summary>
|
||||
public bool NextSiegeState() => Due(ref this._siegeState, SiegeStateBroadcastTicks);
|
||||
|
||||
/// <summary>Advances the castle-flag-broadcast counter and tells whether it is due.</summary>
|
||||
public bool NextCastleFlag() => Due(ref this._castleFlag, CastleFlagBroadcastTicks);
|
||||
|
||||
private static bool Due(ref int counter, int period)
|
||||
{
|
||||
if (++counter < period)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
counter = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user