feat(CS-P3): PROPER siege - position-based Crown Switch hold (stand on them) + gates & statues destructibles + auto throne capture; remove non-working talk handlers
Some checks failed
.NET Core / build (push) Has been cancelled

This commit is contained in:
Acentech Dev
2026-07-15 02:28:14 +03:00
parent c50af5e5c2
commit db8bdd394f
5 changed files with 171 additions and 245 deletions

View File

@@ -5,16 +5,23 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// In-memory Castle Siege phase state machine (P1 skeleton: no battle/persistence).
/// In-memory Castle Siege phase state machine and battle contention (P3).
/// Time is injected via method parameters so it can be tested deterministically.
/// 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.
/// </summary>
public class CastleSiegeContext
{
/// <summary>The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.</summary>
public static readonly short[] SwitchNumbers = { 217, 218 };
private readonly List<string> _registeredGuilds = new();
private readonly Dictionary<short, string> _switchHolders = new();
private readonly Dictionary<short, string?> _switchHolders = new() { { 217, null }, { 218, null } };
private DateTime _phaseStartedUtc;
private string? _occupier;
private int _statuesRemaining;
private int _defensesRemaining;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param>
@@ -36,12 +43,15 @@ public class CastleSiegeContext
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
public string? OwnerGuildName { get; private set; }
/// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
public string? OccupierGuildName => this._occupier;
/// <summary>Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.</summary>
public int DefensesRemaining => this._defensesRemaining;
/// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now)
@@ -77,13 +87,13 @@ public class CastleSiegeContext
break;
case CastleSiegePhase.Settlement:
// Winner = guild holding the throne at siege end (P3). If none captured, owner unchanged.
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier);
}
this._occupier = null;
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
default:
break;
@@ -97,9 +107,7 @@ public class CastleSiegeContext
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
this._switchHolders.Clear();
this._statuesRemaining = 0;
this._occupier = null;
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Registration, now);
}
@@ -109,14 +117,12 @@ public class CastleSiegeContext
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
=> this.TransitionAsync(phase, now);
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations.</summary>
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
this._switchHolders.Clear();
this._statuesRemaining = 0;
this._occupier = null;
this.ClearBattleState();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
}
@@ -135,69 +141,70 @@ public class CastleSiegeContext
/// <param name="guildName">The owner guild name, or null to clear.</param>
public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
/// <summary>Gets the number of guardian statues still standing (must be 0 before the throne can be taken).</summary>
public int StatuesRemaining => this._statuesRemaining;
/// <summary>Sets how many castle defenses (gates + guardian statues) exist this siege (when spawned).</summary>
/// <param name="count">The defense count.</param>
public void SetDefenseCount(int count) => this._defensesRemaining = count > 0 ? count : 0;
/// <summary>Sets how many guardian statues defend the castle this siege (called when they are spawned).</summary>
/// <param name="count">The statue count.</param>
public void SetStatueCount(int count) => this._statuesRemaining = count > 0 ? count : 0;
/// <summary>Notifies that a guardian statue was destroyed. Lowers the remaining count (min 0).</summary>
public void NotifyStatueDestroyed()
/// <summary>Notifies that a castle defense (gate/statue) was destroyed. Lowers the remaining count (min 0).</summary>
public void NotifyDefenseDestroyed()
{
if (this._statuesRemaining > 0)
if (this._defensesRemaining > 0)
{
this._statuesRemaining--;
this._defensesRemaining--;
}
}
/// <summary>
/// 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.
/// 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.
/// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The activating guild's name.</param>
public void HoldSwitch(short switchNumber, string guildName)
/// <param name="guildName">The holding guild's name, or null.</param>
public void SetSwitchHolder(short switchNumber, string? guildName)
{
if (this.Phase == CastleSiegePhase.Siege)
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
{
this._switchHolders[switchNumber] = guildName;
}
}
/// <summary>
/// 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.
/// 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.
/// </summary>
/// <param name="guildName">The capturing guild's name.</param>
/// <returns>A tuple of success and a human-readable reason/result message.</returns>
public (bool Success, string Reason) TryCaptureThrone(string guildName)
/// <returns>The guild that just captured the throne (for announcing), or <c>null</c> if nothing changed.</returns>
public string? EvaluateCapture()
{
if (this.Phase != CastleSiegePhase.Siege)
if (this.Phase != CastleSiegePhase.Siege || this._occupier is not null || this._defensesRemaining > 0)
{
return (false, "The siege is not running.");
return null;
}
if (this._statuesRemaining > 0)
var holder217 = this._switchHolders[217];
var holder218 = this._switchHolders[218];
if (holder217 is not null && holder217 == holder218)
{
return (false, $"Destroy the guardian statues first ({this._statuesRemaining} remaining).");
this._occupier = holder217;
return holder217;
}
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");
return null;
}
/// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}]";
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}], "
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, "
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}";
private void ClearBattleState()
{
this._switchHolders[217] = null;
this._switchHolders[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
}
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
{