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:
@@ -165,6 +165,16 @@ public enum NpcWindow
|
||||
/// The dialog for the legacy quest system.
|
||||
/// </summary>
|
||||
LegacyQuest,
|
||||
|
||||
/// <summary>
|
||||
/// The castle siege gate NPC interaction window.
|
||||
/// </summary>
|
||||
CastleSiegeGateNpc,
|
||||
|
||||
/// <summary>
|
||||
/// The castle siege lever NPC interaction window.
|
||||
/// </summary>
|
||||
CastleSiegeLeverNpc,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -27,10 +27,12 @@ using MUnique.OpenMU.DataModel.Configuration;
|
||||
/// re-create under the same name) therefore can no longer transfer castle ownership to the wrong guild. Names
|
||||
/// are carried alongside purely for display and for the packets that send a name to the client.
|
||||
/// </para>
|
||||
/// Battle rule: attackers must destroy all castle defenses (gates + guardian statues) and then hold BOTH
|
||||
/// Crown Switches at the same time — the switches are held by standing on them (evaluated per tick by the
|
||||
/// plugin), and once both are held by one guild with the defenses down, that guild captures the throne.
|
||||
/// The throne holder when the siege ends becomes the castle owner.
|
||||
/// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is
|
||||
/// operated by clicking it and then staying in its area: the operation needs
|
||||
/// <see cref="CastleSiegeSettings.SwitchPushSeconds"/> to complete, after which the switch counts as held
|
||||
/// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its
|
||||
/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the
|
||||
/// switches do; whoever holds it when the siege ends becomes the castle owner.
|
||||
/// </summary>
|
||||
public class CastleSiegeContext
|
||||
{
|
||||
@@ -38,7 +40,7 @@ public class CastleSiegeContext
|
||||
public static readonly short[] SwitchNumbers = { 217, 218 };
|
||||
|
||||
private readonly Dictionary<Guid, string> _registeredGuilds = new();
|
||||
private readonly Dictionary<short, Guid?> _switchHolders = new() { { 217, null }, { 218, null } };
|
||||
private readonly Dictionary<short, CastleSiegeSwitchOperation?> _switches = new() { { 217, null }, { 218, null } };
|
||||
private DateTime _stateStartedUtc;
|
||||
private Guid? _occupier;
|
||||
private string? _occupierName;
|
||||
@@ -46,6 +48,7 @@ public class CastleSiegeContext
|
||||
private bool _dirty;
|
||||
private Guid? _crownHoldGuild;
|
||||
private DateTime? _crownHoldStartUtc;
|
||||
private Guid? _crownHoldRequestedBy;
|
||||
private bool _lastShieldDown;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
||||
@@ -270,27 +273,46 @@ public class CastleSiegeContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
|
||||
/// crown's shield is dropped for them), or null. Only meaningful during the siege.
|
||||
/// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it,
|
||||
/// or null. Only meaningful during the siege.
|
||||
/// </summary>
|
||||
public Guid? GetShieldEligibleGuild()
|
||||
{
|
||||
if (!this.IsSiegeRunning || this._defensesRemaining > 0)
|
||||
if (!this.IsSiegeRunning)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var holder = this._switchHolders[217];
|
||||
return holder is not null && holder == this._switchHolders[218] ? holder : null;
|
||||
var first = this.GetHeldSwitchGuild(217);
|
||||
return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild with both switches held
|
||||
/// and no defenses left (shield down); <paramref name="masterHolding"/> is whether that guild's master is
|
||||
/// standing on the crown. Captures the throne for the guild once it has held for <paramref name="holdDuration"/>.
|
||||
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
|
||||
/// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
|
||||
/// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
|
||||
/// hold both switches, so a click can never arm a hold the guild isn't entitled to.
|
||||
/// </summary>
|
||||
/// <param name="eligibleGuild">The guild with both switches and no defenses, or null.</param>
|
||||
/// <param name="guildId">The requesting guild master's guild identifier.</param>
|
||||
/// <returns><see langword="true"/> if the request was accepted.</returns>
|
||||
public bool RequestCrownHold(Guid guildId)
|
||||
{
|
||||
if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
this._crownHoldRequestedBy = guildId;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Advances the crown-hold capture. <paramref name="eligibleGuild"/> is the guild holding both switches
|
||||
/// (shield down) and <paramref name="masterHolding"/> is whether that guild's master stands on the crown.
|
||||
/// The hold only runs after the master requested it via <see cref="RequestCrownHold"/>; it captures the
|
||||
/// throne once it ran for <paramref name="holdDuration"/>. Losing a switch or the master leaving the crown
|
||||
/// resets the hold, and the crown has to be clicked again (contestable until the siege ends).
|
||||
/// </summary>
|
||||
/// <param name="eligibleGuild">The guild holding both switches, or null.</param>
|
||||
/// <param name="eligibleGuildName">That guild's name, for display.</param>
|
||||
/// <param name="masterHolding">Whether that guild's master is on the crown.</param>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
@@ -303,21 +325,21 @@ public class CastleSiegeContext
|
||||
|
||||
if (!this.IsSiegeRunning)
|
||||
{
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this.ResetCrownHold();
|
||||
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
|
||||
}
|
||||
|
||||
var wasHolding = this._crownHoldGuild is not null;
|
||||
|
||||
// The guild that already occupies the throne just holds it — no re-registration (avoids a capture loop).
|
||||
// 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;
|
||||
var canCapture = eligibleGuild is not null
|
||||
&& eligibleGuild != this._occupier
|
||||
&& this._crownHoldRequestedBy == eligibleGuild;
|
||||
|
||||
if (!canCapture || !masterHolding)
|
||||
{
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this.ResetCrownHold();
|
||||
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
|
||||
}
|
||||
|
||||
@@ -332,8 +354,7 @@ public class CastleSiegeContext
|
||||
{
|
||||
this._occupier = eligibleGuild;
|
||||
this._occupierName = eligibleGuildName;
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this.ResetCrownHold();
|
||||
this._dirty = true;
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
|
||||
}
|
||||
@@ -392,56 +413,83 @@ public class CastleSiegeContext
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns who is currently operating a Crown Switch, or <see langword="null"/>.</summary>
|
||||
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
|
||||
public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber)
|
||||
=> this._switches.TryGetValue(switchNumber, out var operation) ? operation : null;
|
||||
|
||||
/// <summary>
|
||||
/// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
|
||||
/// based on player positions. Pass <c>null</c> when no registered member stands on it. No-op outside the siege.
|
||||
/// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
|
||||
/// player at a time: while somebody else is on it, the click is refused and the caller is told who holds
|
||||
/// it, which is what the client shows as "another siege team is running the crown switch".
|
||||
/// </summary>
|
||||
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
|
||||
/// <param name="guildId">The holding guild's persistent identifier, or null.</param>
|
||||
public void SetSwitchHolder(short switchNumber, Guid? guildId)
|
||||
/// <param name="guildId">The clicking player's guild identifier.</param>
|
||||
/// <param name="guildName">The clicking player's guild name, for display.</param>
|
||||
/// <param name="playerId">The clicking player's object identifier on the map.</param>
|
||||
/// <param name="playerName">The clicking player's name, for display.</param>
|
||||
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
/// <returns>The outcome, and the current operation when the switch is taken.</returns>
|
||||
public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation(
|
||||
short switchNumber,
|
||||
Guid guildId,
|
||||
string guildName,
|
||||
ushort playerId,
|
||||
string playerName,
|
||||
ushort switchObjectId,
|
||||
DateTime now)
|
||||
{
|
||||
if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber))
|
||||
if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber))
|
||||
{
|
||||
this._switchHolders[switchNumber] = guildId;
|
||||
return (CastleSiegeSwitchPush.SiegeNotRunning, null);
|
||||
}
|
||||
|
||||
if (this._switches[switchNumber] is { } current)
|
||||
{
|
||||
return current.PlayerId == playerId
|
||||
? (CastleSiegeSwitchPush.AlreadyYours, current)
|
||||
: (CastleSiegeSwitchPush.TakenByOther, current);
|
||||
}
|
||||
|
||||
var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now);
|
||||
this._switches[switchNumber] = operation;
|
||||
return (CastleSiegeSwitchPush.Started, operation);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
|
||||
/// area, and completes - which makes the switch count for the guild - once it ran <paramref name="pushDuration"/>.
|
||||
/// </summary>
|
||||
/// <param name="guildId">The capturing guild's persistent identifier.</param>
|
||||
/// <param name="guildName">The capturing guild's name, for display.</param>
|
||||
/// <returns>Whether it succeeded and a human-readable reason/result message.</returns>
|
||||
public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName)
|
||||
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
|
||||
/// <param name="operatorPresent">Whether the operating player is still in the switch's area.</param>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
/// <param name="pushDuration">How long operating the switch takes.</param>
|
||||
/// <returns>What happened to the switch in this tick, and the operation it happened to.</returns>
|
||||
public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch(
|
||||
short switchNumber,
|
||||
bool operatorPresent,
|
||||
DateTime now,
|
||||
TimeSpan pushDuration)
|
||||
{
|
||||
if (!this.IsSiegeRunning)
|
||||
if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null)
|
||||
{
|
||||
return (false, "The siege is not running.");
|
||||
return (CastleSiegeSwitchEvent.None, null);
|
||||
}
|
||||
|
||||
if (this._occupier is { } occupier)
|
||||
if (!this.IsSiegeRunning || !operatorPresent)
|
||||
{
|
||||
return (false, occupier == guildId
|
||||
? "Your guild already holds the throne."
|
||||
: $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'.");
|
||||
this._switches[switchNumber] = null;
|
||||
return (CastleSiegeSwitchEvent.Released, operation);
|
||||
}
|
||||
|
||||
if (this._defensesRemaining > 0)
|
||||
if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration)
|
||||
{
|
||||
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
|
||||
operation.MarkHeld();
|
||||
return (CastleSiegeSwitchEvent.Held, operation);
|
||||
}
|
||||
|
||||
if (this._switchHolders[217] != guildId || this._switchHolders[218] != guildId)
|
||||
{
|
||||
return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
|
||||
}
|
||||
|
||||
this._occupier = guildId;
|
||||
this._occupierName = guildName;
|
||||
this._dirty = true;
|
||||
return (true, "throne captured");
|
||||
return (CastleSiegeSwitchEvent.None, operation);
|
||||
}
|
||||
|
||||
/// <summary>Returns a human-readable status summary for admin display.</summary>
|
||||
@@ -451,20 +499,29 @@ public class CastleSiegeContext
|
||||
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
|
||||
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
|
||||
|
||||
private Guid? GetHeldSwitchGuild(short switchNumber)
|
||||
=> this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null;
|
||||
|
||||
private string DescribeSwitch(short switchNumber)
|
||||
=> this._switchHolders[switchNumber] is { } holder
|
||||
? (this._registeredGuilds.TryGetValue(holder, out var name) ? name : holder.ToString())
|
||||
=> this._switches[switchNumber] is { } operation
|
||||
? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}"
|
||||
: "-";
|
||||
|
||||
private void ResetCrownHold()
|
||||
{
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this._crownHoldRequestedBy = null;
|
||||
}
|
||||
|
||||
private void ClearBattleState()
|
||||
{
|
||||
this._switchHolders[217] = null;
|
||||
this._switchHolders[218] = null;
|
||||
this._switches[217] = null;
|
||||
this._switches[218] = null;
|
||||
this._defensesRemaining = 0;
|
||||
this._occupier = null;
|
||||
this._occupierName = null;
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this.ResetCrownHold();
|
||||
this._lastShieldDown = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -117,6 +117,12 @@ public class CastleSiegeSettings
|
||||
[Browsable(false)]
|
||||
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
|
||||
/// The player has to stay in the switch's area for that long, and keeps it until they leave.
|
||||
/// </summary>
|
||||
public int SwitchPushSeconds { get; set; } = 15;
|
||||
|
||||
// --- Persisted cycle bookkeeping (hidden from the AdminPanel) ---
|
||||
// Only the CURRENT state and when it started ride on the plugin's custom-configuration JSON. The castle
|
||||
// owner and the guild registrations live in real database tables, so they are not duplicated here.
|
||||
|
||||
20
src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
Normal file
20
src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
// <copyright file="CastleSiegeSwitchEvent.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
|
||||
/// <summary>
|
||||
/// What happened to a Crown Switch during one tick.
|
||||
/// </summary>
|
||||
public enum CastleSiegeSwitchEvent
|
||||
{
|
||||
/// <summary>Nothing worth reporting.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>The operation completed, so the switch now counts for the operator's guild.</summary>
|
||||
Held,
|
||||
|
||||
/// <summary>The operator left (or the siege ended), so the switch is free again.</summary>
|
||||
Released,
|
||||
}
|
||||
55
src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
Normal file
55
src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
// <copyright file="CastleSiegeSwitchOperation.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
|
||||
/// <summary>
|
||||
/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by
|
||||
/// staying in its area; the switch counts as held for the guild once the operation has run its time.
|
||||
/// </summary>
|
||||
public class CastleSiegeSwitchOperation
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="CastleSiegeSwitchOperation"/> class.</summary>
|
||||
/// <param name="guildId">The operating player's guild identifier.</param>
|
||||
/// <param name="guildName">The operating player's guild name, for display.</param>
|
||||
/// <param name="playerId">The operating player's object identifier on the map.</param>
|
||||
/// <param name="playerName">The operating player's name, for display.</param>
|
||||
/// <param name="switchObjectId">The switch NPC's object identifier on the map.</param>
|
||||
/// <param name="startedUtc">When the operation started (UTC).</param>
|
||||
public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc)
|
||||
{
|
||||
this.GuildId = guildId;
|
||||
this.GuildName = guildName;
|
||||
this.PlayerId = playerId;
|
||||
this.PlayerName = playerName;
|
||||
this.SwitchObjectId = switchObjectId;
|
||||
this.StartedUtc = startedUtc;
|
||||
}
|
||||
|
||||
/// <summary>Gets the operating player's guild identifier.</summary>
|
||||
public Guid GuildId { get; }
|
||||
|
||||
/// <summary>Gets the operating player's guild name.</summary>
|
||||
public string GuildName { get; }
|
||||
|
||||
/// <summary>Gets the operating player's object identifier on the map.</summary>
|
||||
public ushort PlayerId { get; }
|
||||
|
||||
/// <summary>Gets the operating player's name.</summary>
|
||||
public string PlayerName { get; }
|
||||
|
||||
/// <summary>Gets the switch NPC's object identifier on the map, which the client's packets refer to.</summary>
|
||||
public ushort SwitchObjectId { get; }
|
||||
|
||||
/// <summary>Gets the point in time (UTC) when the operation started.</summary>
|
||||
public DateTime StartedUtc { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation ran its time, so the switch counts for the guild.
|
||||
/// </summary>
|
||||
public bool IsHeld { get; private set; }
|
||||
|
||||
/// <summary>Marks the operation as completed, which makes the switch count for the guild.</summary>
|
||||
internal void MarkHeld() => this.IsHeld = true;
|
||||
}
|
||||
23
src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
Normal file
23
src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
Normal file
@@ -0,0 +1,23 @@
|
||||
// <copyright file="CastleSiegeSwitchPush.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
|
||||
/// <summary>
|
||||
/// The outcome of a player clicking a Crown Switch.
|
||||
/// </summary>
|
||||
public enum CastleSiegeSwitchPush
|
||||
{
|
||||
/// <summary>The player started operating the switch.</summary>
|
||||
Started,
|
||||
|
||||
/// <summary>The player is already operating this switch.</summary>
|
||||
AlreadyYours,
|
||||
|
||||
/// <summary>Somebody else is operating this switch.</summary>
|
||||
TakenByOther,
|
||||
|
||||
/// <summary>The siege is not running, so the switches do nothing.</summary>
|
||||
SiegeNotRunning,
|
||||
}
|
||||
92
src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
Normal file
92
src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
Normal file
@@ -0,0 +1,92 @@
|
||||
// <copyright file="CastleSiegeSwitchTalkPlugIn.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
|
||||
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.GameLogic.Views.CastleSiege;
|
||||
using MUnique.OpenMU.Interfaces;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
|
||||
/// <summary>
|
||||
/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the
|
||||
/// switch, which the client shows as a progress box; the switch counts for the guild once the operation
|
||||
/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can
|
||||
/// operate a switch at a time - anybody else clicking it is told that another team is on it.
|
||||
/// </summary>
|
||||
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")]
|
||||
[PlugIn]
|
||||
[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")]
|
||||
public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
|
||||
{
|
||||
if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We drive the switch ourselves, so suppress the default "not implemented" message.
|
||||
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 (!context.IsSiegeRunning)
|
||||
{
|
||||
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild
|
||||
|| !context.IsRegistered(guild.Id))
|
||||
{
|
||||
await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
var (result, operation) = context.TryStartSwitchOperation(
|
||||
npc.Definition.Number,
|
||||
guild.Id,
|
||||
guild.Name,
|
||||
player.Id,
|
||||
player.Name,
|
||||
npc.Id,
|
||||
DateTime.UtcNow);
|
||||
|
||||
switch (result)
|
||||
{
|
||||
case CastleSiegeSwitchPush.Started:
|
||||
// The info packet goes first: it is what makes every client allocate its switch table, which
|
||||
// the "switch released" packet later reads without checking that it exists.
|
||||
await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false);
|
||||
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case CastleSiegeSwitchPush.TakenByOther when operation is { } other:
|
||||
// State 2 makes the client name the player who is already on it.
|
||||
await player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false);
|
||||
break;
|
||||
|
||||
case CastleSiegeSwitchPush.AlreadyYours:
|
||||
break;
|
||||
|
||||
default:
|
||||
await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static ValueTask ShowAsync(Player player, string text)
|
||||
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
|
||||
}
|
||||
@@ -55,34 +55,46 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state.
|
||||
await ShowAsync(player, DescribeThroneStep(context, guild.Id)).ConfigureAwait(false);
|
||||
// Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
|
||||
// stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing.
|
||||
var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster;
|
||||
if (isGuildMaster && context.RequestCrownHold(guild.Id))
|
||||
{
|
||||
await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId)
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
|
||||
{
|
||||
if (!context.IsSiegeRunning)
|
||||
{
|
||||
return "The siege is not running yet.";
|
||||
}
|
||||
|
||||
if (context.DefensesRemaining > 0)
|
||||
{
|
||||
return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
|
||||
}
|
||||
|
||||
var eligible = context.GetShieldEligibleGuild();
|
||||
if (eligible is null)
|
||||
{
|
||||
return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
|
||||
return context.DefensesRemaining > 0
|
||||
? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)."
|
||||
: "Hold BOTH Crown Switches with your guild - the Crown's shield will drop.";
|
||||
}
|
||||
|
||||
if (eligible == guildId)
|
||||
if (eligible != guildId)
|
||||
{
|
||||
return "Your guild holds both switches and the shield is down — send your GUILD MASTER to hold the Crown to take the throne!";
|
||||
return "Another guild is holding both switches. Take a switch back to raise their shield.";
|
||||
}
|
||||
|
||||
return "Another guild is holding both switches. Take a switch back to raise their shield.";
|
||||
if (context.OccupierGuildId == guildId)
|
||||
{
|
||||
return "Your guild already holds the throne - keep it until the siege ends.";
|
||||
}
|
||||
|
||||
return isGuildMaster
|
||||
? "Your guild holds both switches, but the Crown cannot be registered right now."
|
||||
: "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!";
|
||||
}
|
||||
|
||||
private static ValueTask ShowAsync(Player player, string text)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
|
||||
/// </summary>
|
||||
/// <param name="guildName">The capturing guild's name (max 8 bytes).</param>
|
||||
ValueTask AnnounceSealCapturedAsync(string guildName);
|
||||
|
||||
/// <summary>
|
||||
/// Sends the state of a Crown Switch (C1 B2 14): state 0 = released (the client closes its progress
|
||||
/// box), 1 = this player is operating it (the client opens its hold progress box), 2 = somebody else
|
||||
/// is already operating it.
|
||||
/// </summary>
|
||||
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
|
||||
/// <param name="playerObjectId">The operating player's object identifier.</param>
|
||||
/// <param name="state">The switch state (0 released, 1 operated by this player, 2 operated by another).</param>
|
||||
ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state);
|
||||
|
||||
/// <summary>
|
||||
/// Sends who is operating a Crown Switch (C1 B2 20), which the client lists on the siege HUD.
|
||||
/// <para>
|
||||
/// This has to reach a client BEFORE any <see cref="SetCrownSwitchStateAsync"/> with state 0: the client
|
||||
/// allocates its switch table when this arrives, and its "switch released" handler reads that table
|
||||
/// without checking whether it exists.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="switchObjectId">The Crown Switch NPC's object identifier.</param>
|
||||
/// <param name="switchState">0 when nobody operates it, 1 while it is operated.</param>
|
||||
/// <param name="joinSide">The operating side (see the castle siege join sides).</param>
|
||||
/// <param name="guildName">The operating guild's name (max 8 bytes), empty when free.</param>
|
||||
/// <param name="playerName">The operating player's name (max 10 bytes), empty when free.</param>
|
||||
ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,17 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
|
||||
/// <param name="player">The player.</param>
|
||||
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
|
||||
|
||||
private static void WriteName(string name, Span<byte> target)
|
||||
{
|
||||
if (name.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(name);
|
||||
bytes.AsSpan(0, Math.Min(target.Length - 1, bytes.Length)).CopyTo(target);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetBattleStateAsync(bool started)
|
||||
{
|
||||
@@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
|
||||
await connection.SendAsync(WritePacket).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state)
|
||||
{
|
||||
var connection = this._player.Connection;
|
||||
if (connection is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int WritePacket()
|
||||
{
|
||||
// C1 09 B2 14 <switchId:2 BE> <playerId:2 BE> <state>
|
||||
var span = connection.Output.GetSpan(9)[..9];
|
||||
span.Clear();
|
||||
span[0] = 0xC1;
|
||||
span[1] = 0x09;
|
||||
span[2] = 0xB2;
|
||||
span[3] = 0x14;
|
||||
span[4] = (byte)(switchObjectId >> 8);
|
||||
span[5] = (byte)switchObjectId;
|
||||
span[6] = (byte)(playerObjectId >> 8);
|
||||
span[7] = (byte)playerObjectId;
|
||||
span[8] = state;
|
||||
return span.Length;
|
||||
}
|
||||
|
||||
await connection.SendAsync(WritePacket).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName)
|
||||
{
|
||||
var connection = this._player.Connection;
|
||||
if (connection is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int WritePacket()
|
||||
{
|
||||
// C1 1B B2 20 <switchId:2 BE> <switchState> <joinSide> <guildName[8]> <playerName[11]>
|
||||
var span = connection.Output.GetSpan(27)[..27];
|
||||
span.Clear();
|
||||
span[0] = 0xC1;
|
||||
span[1] = 0x1B;
|
||||
span[2] = 0xB2;
|
||||
span[3] = 0x20;
|
||||
span[4] = (byte)(switchObjectId >> 8);
|
||||
span[5] = (byte)switchObjectId;
|
||||
span[6] = switchState;
|
||||
span[7] = joinSide;
|
||||
WriteName(guildName, span.Slice(8, 8));
|
||||
WriteName(playerName, span.Slice(16, 10));
|
||||
return span.Length;
|
||||
}
|
||||
|
||||
await connection.SendAsync(WritePacket).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask AnnounceSealCapturedAsync(string guildName)
|
||||
{
|
||||
|
||||
@@ -46,7 +46,7 @@ internal sealed class CastleSiegeInitializer : InitializerBase
|
||||
|
||||
var configuration = this.Context.CreateNew<CastleSiegeConfiguration>();
|
||||
configuration.Enabled = true;
|
||||
configuration.CrownHoldTimeSeconds = 30;
|
||||
configuration.CrownHoldTimeSeconds = 60; // The client's crown registration panel counts down from 60s.
|
||||
configuration.RegisterMinLevel = 200;
|
||||
configuration.RegisterMinMembers = 20;
|
||||
configuration.ParticipantRewardMinSeconds = 60;
|
||||
|
||||
Reference in New Issue
Block a user