refactor(castle-siege): drive the cycle on the client's state numbers and persist guilds by id
Moves AdaMu's working Castle Siege onto the upstream data model that the previous commit introduced, without changing how the siege plays. State model - CastleSiegePhase is replaced by DataModel's CastleSiegeState, whose values are exactly what the game client's CASTLESIEGE_STATE enum expects. The cycle now runs Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0). - Idle2(2), RegisterMark(3), Idle3(4) and Notify(5) keep their numbers for client compatibility but are never entered: AdaMu registers guilds directly and has no Mark of Lord step. Guild identity - Guilds are now identified by their persistent Guid instead of by name, so a rename (or a delete and re-create under the same name) can no longer hand castle ownership to the wrong guild. Names are carried alongside only for display and for the packets that send a name to the client. - Interfaces.Guild deliberately has no id and the guild server's short ids are in-memory only, so the persistent id is resolved through the guild name once and cached per process. This avoids adding a method to IGuildServer, which upstream keeps changing. Persistence - The castle owner is stored in the CastleSiegeData row and the registrations in CastleSiegeGuildRegistration rows, replacing the previous plugin-configuration JSON blob. Only the current state and when it started still ride on the plugin configuration, because they have no column in the upstream schema. Castle NPCs - The hard-coded gate, catapult, crown and switch coordinates are gone. They are read from GameConfiguration.CastleSiegeConfiguration, seeded by CastleSiegeInitializer. Definitions flagged IsPersistedToDatabase are the breakable defenses and count towards the throne, which additionally brings in the 4 guardian statues the previous implementation did not spawn. - The crown hold time now comes from the seeded configuration instead of the plugin settings. The AdaMu operational settings (cycle durations, registration fee, designated server id, auto-open schedule) moved to a renamed CastleSiegeSettings class, so they no longer collide with upstream's CastleSiegeConfiguration entity. Verified: full server build succeeds with 0 errors. Not yet done: the 0xB2 0x00 CastleSiegeState request handler, and the docker / local run.
This commit is contained in:
@@ -4,9 +4,29 @@
|
||||
|
||||
namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
|
||||
using MUnique.OpenMU.DataModel.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory Castle Siege phase state machine and battle contention (P3).
|
||||
/// In-memory Castle Siege state machine and battle contention.
|
||||
/// Time is injected via method parameters so it can be tested deterministically.
|
||||
/// <para>
|
||||
/// The cycle uses the original Season 6 <see cref="CastleSiegeState"/> values, which are exactly the values
|
||||
/// the game client expects (see <c>CASTLESIEGE_STATE</c> in the client's <c>WSclient.h</c>). AdaMu drives only
|
||||
/// a subset of them, because it registers guilds directly and has no Mark of Lord step:
|
||||
/// </para>
|
||||
/// <code>
|
||||
/// Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0)
|
||||
/// </code>
|
||||
/// <para>
|
||||
/// The skipped states (<see cref="CastleSiegeState.Idle2"/>, <see cref="CastleSiegeState.RegisterMark"/>,
|
||||
/// <see cref="CastleSiegeState.Idle3"/>, <see cref="CastleSiegeState.Notify"/>) keep their numbers so the
|
||||
/// client stays compatible; the server simply never enters them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Guilds are identified by their persistent <see cref="Guid"/>, not by name. A guild rename (or a delete and
|
||||
/// 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.
|
||||
@@ -17,96 +37,115 @@ 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() { { 217, null }, { 218, null } };
|
||||
private DateTime _phaseStartedUtc;
|
||||
private string? _occupier;
|
||||
private readonly Dictionary<Guid, string> _registeredGuilds = new();
|
||||
private readonly Dictionary<short, Guid?> _switchHolders = new() { { 217, null }, { 218, null } };
|
||||
private DateTime _stateStartedUtc;
|
||||
private Guid? _occupier;
|
||||
private string? _occupierName;
|
||||
private int _defensesRemaining;
|
||||
private bool _dirty;
|
||||
private string? _crownHoldGuild;
|
||||
private Guid? _crownHoldGuild;
|
||||
private DateTime? _crownHoldStartUtc;
|
||||
private bool _lastShieldDown;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
|
||||
/// <param name="configuration">The cycle timing configuration.</param>
|
||||
public CastleSiegeContext(CastleSiegeConfiguration configuration)
|
||||
public CastleSiegeContext(CastleSiegeSettings configuration)
|
||||
{
|
||||
this.Configuration = configuration;
|
||||
this.Phase = CastleSiegePhase.Ownership;
|
||||
this.State = CastleSiegeState.Idle1;
|
||||
}
|
||||
|
||||
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
|
||||
public event Action<CastleSiegePhase>? PhaseChanged;
|
||||
/// <summary>Raised after the state changes. Argument is the new state.</summary>
|
||||
public event Action<CastleSiegeState>? StateChanged;
|
||||
|
||||
/// <summary>Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
|
||||
/// so AdminPanel edits take effect without a restart.</summary>
|
||||
public CastleSiegeConfiguration Configuration { get; private set; }
|
||||
public CastleSiegeSettings Configuration { get; private set; }
|
||||
|
||||
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
|
||||
/// <param name="configuration">The live configuration.</param>
|
||||
public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
|
||||
/// <summary>Gets the current state.</summary>
|
||||
public CastleSiegeState State { get; private set; }
|
||||
|
||||
/// <summary>Gets the current phase.</summary>
|
||||
public CastleSiegePhase Phase { get; private set; }
|
||||
/// <summary>Gets the UTC time the current state started (used for persistence/restore).</summary>
|
||||
public DateTime StateStartedUtc => this._stateStartedUtc;
|
||||
|
||||
/// <summary>Gets the UTC time the current phase started (used for persistence/restore).</summary>
|
||||
public DateTime PhaseStartedUtc => this._phaseStartedUtc;
|
||||
/// <summary>Gets the persistent identifier of the owner guild, or <see langword="null"/> if unowned.</summary>
|
||||
public Guid? OwnerGuildId { get; private set; }
|
||||
|
||||
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
|
||||
/// <summary>Gets the owner guild's name for display and client packets, or null.</summary>
|
||||
public string? OwnerGuildName { get; private set; }
|
||||
|
||||
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
|
||||
public string? OccupierGuildName => this._occupier;
|
||||
/// <summary>Gets the guild currently holding the throne during the siege, or null.</summary>
|
||||
public Guid? OccupierGuildId => this._occupier;
|
||||
|
||||
/// <summary>Gets the throne holder's name for display and client packets, or null.</summary>
|
||||
public string? OccupierGuildName => this._occupierName;
|
||||
|
||||
/// <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>Gets the persistent identifiers of the guilds registered for the current cycle.</summary>
|
||||
public IReadOnlyCollection<Guid> RegisteredGuildIds => this._registeredGuilds.Keys;
|
||||
|
||||
/// <summary>Gets the names of the guilds registered for the current cycle (display only).</summary>
|
||||
public IReadOnlyCollection<string> RegisteredGuildNames => this._registeredGuilds.Values;
|
||||
|
||||
/// <summary>Gets a value indicating whether the siege battle is currently running.</summary>
|
||||
public bool IsSiegeRunning => this.State == CastleSiegeState.Start;
|
||||
|
||||
/// <summary>Points the context at the current (possibly AdminPanel-edited) plugin configuration.</summary>
|
||||
/// <param name="configuration">The live configuration.</param>
|
||||
public void UpdateConfiguration(CastleSiegeSettings configuration) => this.Configuration = configuration;
|
||||
|
||||
/// <summary>Returns whether the given guild is registered for the current cycle.</summary>
|
||||
/// <param name="guildId">The persistent guild identifier.</param>
|
||||
public bool IsRegistered(Guid guildId) => this._registeredGuilds.ContainsKey(guildId);
|
||||
|
||||
/// <summary>Advances the state machine based on the current time.</summary>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
public ValueTask TickAsync(DateTime now)
|
||||
{
|
||||
switch (this.Phase)
|
||||
switch (this.State)
|
||||
{
|
||||
case CastleSiegePhase.Ownership:
|
||||
case CastleSiegeState.Idle1:
|
||||
if (this.Configuration.IsRegistrationOpenTime(now))
|
||||
{
|
||||
return this.ForceStartRegistrationAsync(now);
|
||||
}
|
||||
|
||||
break;
|
||||
case CastleSiegePhase.Registration:
|
||||
if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
|
||||
case CastleSiegeState.RegisterGuild:
|
||||
if (now >= this._stateStartedUtc + this.Configuration.RegistrationDuration)
|
||||
{
|
||||
return this.TransitionAsync(CastleSiegePhase.Preparation, now);
|
||||
return this.TransitionAsync(CastleSiegeState.Ready, now);
|
||||
}
|
||||
|
||||
break;
|
||||
case CastleSiegePhase.Preparation:
|
||||
if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
|
||||
case CastleSiegeState.Ready:
|
||||
if (now >= this._stateStartedUtc + this.Configuration.PreparationDuration)
|
||||
{
|
||||
return this.TransitionAsync(CastleSiegePhase.Siege, now);
|
||||
return this.TransitionAsync(CastleSiegeState.Start, now);
|
||||
}
|
||||
|
||||
break;
|
||||
case CastleSiegePhase.Siege:
|
||||
if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
|
||||
case CastleSiegeState.Start:
|
||||
if (now >= this._stateStartedUtc + this.Configuration.SiegeDuration)
|
||||
{
|
||||
return this.TransitionAsync(CastleSiegePhase.Settlement, now);
|
||||
return this.TransitionAsync(CastleSiegeState.End, now);
|
||||
}
|
||||
|
||||
break;
|
||||
case CastleSiegePhase.Settlement:
|
||||
case CastleSiegeState.End:
|
||||
// Winner = guild holding the throne at siege end. If none captured, owner unchanged.
|
||||
if (this._occupier is { } occupier)
|
||||
{
|
||||
this.SetOwner(occupier);
|
||||
this.SetOwner(occupier, this._occupierName);
|
||||
}
|
||||
|
||||
this.ClearBattleState();
|
||||
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
||||
return this.TransitionAsync(CastleSiegeState.EndCycle, now);
|
||||
case CastleSiegeState.EndCycle:
|
||||
return this.TransitionAsync(CastleSiegeState.Idle1, now);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -114,62 +153,67 @@ public class CastleSiegeContext
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
|
||||
/// <summary>Admin: forces the cycle into guild registration now (from any state).</summary>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
public ValueTask ForceStartRegistrationAsync(DateTime now)
|
||||
{
|
||||
this._registeredGuilds.Clear();
|
||||
this.ClearBattleState();
|
||||
return this.TransitionAsync(CastleSiegePhase.Registration, now);
|
||||
return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
|
||||
}
|
||||
|
||||
/// <summary>Admin: forces a specific phase now.</summary>
|
||||
/// <param name="phase">The target phase.</param>
|
||||
/// <summary>Admin: forces a specific state now.</summary>
|
||||
/// <param name="state">The target state.</param>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
|
||||
=> this.TransitionAsync(phase, now);
|
||||
public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
|
||||
=> this.TransitionAsync(state, now);
|
||||
|
||||
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations/battle state.</summary>
|
||||
/// <summary>Admin: resets to the idle (resting) state and clears registrations/battle state.</summary>
|
||||
/// <param name="now">The current UTC time.</param>
|
||||
public ValueTask ResetAsync(DateTime now)
|
||||
{
|
||||
this._registeredGuilds.Clear();
|
||||
this.ClearBattleState();
|
||||
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
|
||||
return this.TransitionAsync(CastleSiegeState.Idle1, now);
|
||||
}
|
||||
|
||||
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
|
||||
/// <param name="guildName">The guild name.</param>
|
||||
public void RegisterGuild(string guildName)
|
||||
/// <summary>Registers a guild for the current cycle. No-op outside the registration state.</summary>
|
||||
/// <param name="guildId">The persistent guild identifier.</param>
|
||||
/// <param name="guildName">The guild name, for display.</param>
|
||||
public void RegisterGuild(Guid guildId, string guildName)
|
||||
{
|
||||
if (this.Phase == CastleSiegePhase.Registration
|
||||
&& !this._registeredGuilds.Contains(guildName))
|
||||
if (this.State == CastleSiegeState.RegisterGuild
|
||||
&& this._registeredGuilds.TryAdd(guildId, guildName))
|
||||
{
|
||||
this._registeredGuilds.Add(guildName);
|
||||
this._dirty = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
|
||||
/// <param name="guildName">The owner guild name, or null to clear.</param>
|
||||
public void SetOwner(string? guildName)
|
||||
/// <summary>Admin: sets (or clears) the current owner guild.</summary>
|
||||
/// <param name="guildId">The owner guild identifier, or null to clear.</param>
|
||||
/// <param name="guildName">The owner guild name, or null.</param>
|
||||
public void SetOwner(Guid? guildId, string? guildName)
|
||||
{
|
||||
this.OwnerGuildName = guildName;
|
||||
this.OwnerGuildId = guildId;
|
||||
this.OwnerGuildName = guildId is null ? null : guildName;
|
||||
this._dirty = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mirrors the shared castle owner from the configuration. Used on game servers that do NOT host the
|
||||
/// Mirrors the shared castle owner loaded from the database. Used on game servers that do NOT host the
|
||||
/// siege, so their hunting-map gate and castle flag still reflect the current owner. Does not mark the
|
||||
/// state dirty (these servers never persist).
|
||||
/// </summary>
|
||||
public void SyncOwnerFromConfig()
|
||||
/// <param name="guildId">The owner guild identifier, or null.</param>
|
||||
/// <param name="guildName">The owner guild name, or null.</param>
|
||||
public void SyncOwner(Guid? guildId, string? guildName)
|
||||
{
|
||||
this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
|
||||
this.OwnerGuildId = guildId;
|
||||
this.OwnerGuildName = guildId is null ? null : guildName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the weekly auto-schedule (days of week + UTC time) into the configuration and marks the state
|
||||
/// Sets the auto-open schedule (days of week + UTC time) into the configuration and marks the state
|
||||
/// dirty for persistence. Empty days disables auto-start (manual only). The configuration is the single
|
||||
/// source of truth, so this is equivalent to editing the plugin config in the AdminPanel.
|
||||
/// </summary>
|
||||
@@ -192,12 +236,36 @@ public class CastleSiegeContext
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
public TimeSpan GetRemainingSiegeTime(DateTime nowUtc)
|
||||
{
|
||||
if (this.Phase != CastleSiegePhase.Siege)
|
||||
if (!this.IsSiegeRunning)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var remaining = (this._phaseStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
|
||||
var remaining = (this._stateStartedUtc + this.Configuration.SiegeDuration) - nowUtc;
|
||||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns how much time is left in the current state, or <see cref="TimeSpan.Zero"/> when the state has
|
||||
/// no duration (idle states wait for an admin command or the auto-open time).
|
||||
/// </summary>
|
||||
/// <param name="nowUtc">The current UTC time.</param>
|
||||
public TimeSpan GetRemainingStateTime(DateTime nowUtc)
|
||||
{
|
||||
var duration = this.State switch
|
||||
{
|
||||
CastleSiegeState.RegisterGuild => this.Configuration.RegistrationDuration,
|
||||
CastleSiegeState.Ready => this.Configuration.PreparationDuration,
|
||||
CastleSiegeState.Start => this.Configuration.SiegeDuration,
|
||||
_ => TimeSpan.Zero,
|
||||
};
|
||||
|
||||
if (duration == TimeSpan.Zero)
|
||||
{
|
||||
return TimeSpan.Zero;
|
||||
}
|
||||
|
||||
var remaining = (this._stateStartedUtc + duration) - nowUtc;
|
||||
return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
|
||||
}
|
||||
|
||||
@@ -205,9 +273,9 @@ public class CastleSiegeContext
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public string? GetShieldEligibleGuild()
|
||||
public Guid? GetShieldEligibleGuild()
|
||||
{
|
||||
if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
|
||||
if (!this.IsSiegeRunning || this._defensesRemaining > 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -223,20 +291,21 @@ public class CastleSiegeContext
|
||||
/// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
|
||||
/// </summary>
|
||||
/// <param name="eligibleGuild">The guild with both switches and no defenses, 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>
|
||||
/// <param name="holdDuration">How long the master must hold to capture.</param>
|
||||
public CrownTickResult TickCrownHold(string? eligibleGuild, bool masterHolding, DateTime now, TimeSpan holdDuration)
|
||||
public CrownTickResult TickCrownHold(Guid? eligibleGuild, string? eligibleGuildName, bool masterHolding, DateTime now, TimeSpan holdDuration)
|
||||
{
|
||||
var shieldDown = this.Phase == CastleSiegePhase.Siege && eligibleGuild is not null;
|
||||
var shieldDown = this.IsSiegeRunning && eligibleGuild is not null;
|
||||
var shieldChanged = shieldDown != this._lastShieldDown;
|
||||
this._lastShieldDown = shieldDown;
|
||||
|
||||
if (this.Phase != CastleSiegePhase.Siege)
|
||||
if (!this.IsSiegeRunning)
|
||||
{
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null);
|
||||
return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
|
||||
}
|
||||
|
||||
var wasHolding = this._crownHoldGuild is not null;
|
||||
@@ -249,26 +318,27 @@ public class CastleSiegeContext
|
||||
{
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
|
||||
return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null, null);
|
||||
}
|
||||
|
||||
if (this._crownHoldGuild != eligibleGuild || this._crownHoldStartUtc is null)
|
||||
{
|
||||
this._crownHoldGuild = eligibleGuild;
|
||||
this._crownHoldStartUtc = now;
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild);
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.HoldStarted, eligibleGuild, eligibleGuildName);
|
||||
}
|
||||
|
||||
if (now - this._crownHoldStartUtc.Value >= holdDuration)
|
||||
{
|
||||
this._occupier = eligibleGuild;
|
||||
this._occupierName = eligibleGuildName;
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this._dirty = true;
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild);
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.Captured, eligibleGuild, eligibleGuildName);
|
||||
}
|
||||
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild);
|
||||
return new CrownTickResult(shieldDown, shieldChanged, CrownEvent.None, eligibleGuild, eligibleGuildName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -283,22 +353,27 @@ public class CastleSiegeContext
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without
|
||||
/// firing <see cref="PhaseChanged"/> or marking the state dirty. Battle state stays cleared.
|
||||
/// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
|
||||
/// firing <see cref="StateChanged"/> or marking the state dirty. Battle state stays cleared.
|
||||
/// </summary>
|
||||
/// <param name="owner">The persisted owner guild name, or null.</param>
|
||||
/// <param name="phase">The persisted phase.</param>
|
||||
/// <param name="phaseStartedUtc">When the persisted phase started (UTC), or null to keep the default.</param>
|
||||
/// <param name="registeredGuilds">The persisted registered guild names, or null.</param>
|
||||
public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable<string>? registeredGuilds)
|
||||
/// <param name="ownerGuildId">The persisted owner guild identifier, or null.</param>
|
||||
/// <param name="ownerGuildName">The persisted owner guild name, or null.</param>
|
||||
/// <param name="state">The persisted state.</param>
|
||||
/// <param name="stateStartedUtc">When the persisted state started (UTC), or null to keep the default.</param>
|
||||
/// <param name="registeredGuilds">The persisted registrations (id to name), or null.</param>
|
||||
public void RestoreState(Guid? ownerGuildId, string? ownerGuildName, CastleSiegeState state, DateTime? stateStartedUtc, IEnumerable<KeyValuePair<Guid, string>>? registeredGuilds)
|
||||
{
|
||||
this.OwnerGuildName = owner;
|
||||
this.Phase = phase;
|
||||
this._phaseStartedUtc = phaseStartedUtc ?? this._phaseStartedUtc;
|
||||
this.OwnerGuildId = ownerGuildId;
|
||||
this.OwnerGuildName = ownerGuildId is null ? null : ownerGuildName;
|
||||
this.State = state;
|
||||
this._stateStartedUtc = stateStartedUtc ?? this._stateStartedUtc;
|
||||
this._registeredGuilds.Clear();
|
||||
if (registeredGuilds is not null)
|
||||
{
|
||||
this._registeredGuilds.AddRange(registeredGuilds);
|
||||
foreach (var registration in registeredGuilds)
|
||||
{
|
||||
this._registeredGuilds[registration.Key] = registration.Value;
|
||||
}
|
||||
}
|
||||
|
||||
this._dirty = false;
|
||||
@@ -322,12 +397,12 @@ public class CastleSiegeContext
|
||||
/// 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 holding guild's name, or null.</param>
|
||||
public void SetSwitchHolder(short switchNumber, string? guildName)
|
||||
/// <param name="guildId">The holding guild's persistent identifier, or null.</param>
|
||||
public void SetSwitchHolder(short switchNumber, Guid? guildId)
|
||||
{
|
||||
if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
|
||||
if (this.IsSiegeRunning && this._switchHolders.ContainsKey(switchNumber))
|
||||
{
|
||||
this._switchHolders[switchNumber] = guildName;
|
||||
this._switchHolders[switchNumber] = guildId;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -336,20 +411,21 @@ public class CastleSiegeContext
|
||||
/// 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).
|
||||
/// </summary>
|
||||
/// <param name="guildName">The capturing guild's name.</param>
|
||||
/// <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(string guildName)
|
||||
public (bool Success, string Reason) TryCaptureThrone(Guid guildId, string guildName)
|
||||
{
|
||||
if (this.Phase != CastleSiegePhase.Siege)
|
||||
if (!this.IsSiegeRunning)
|
||||
{
|
||||
return (false, "The siege is not running.");
|
||||
}
|
||||
|
||||
if (this._occupier is not null)
|
||||
if (this._occupier is { } occupier)
|
||||
{
|
||||
return (false, this._occupier == guildName
|
||||
return (false, occupier == guildId
|
||||
? "Your guild already holds the throne."
|
||||
: $"The throne is already held by '{this._occupier}'.");
|
||||
: $"The throne is already held by '{this._occupierName ?? occupier.ToString()}'.");
|
||||
}
|
||||
|
||||
if (this._defensesRemaining > 0)
|
||||
@@ -357,21 +433,28 @@ public class CastleSiegeContext
|
||||
return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
|
||||
}
|
||||
|
||||
if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName)
|
||||
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 = guildName;
|
||||
this._occupier = guildId;
|
||||
this._occupierName = guildName;
|
||||
this._dirty = true;
|
||||
return (true, "throne captured");
|
||||
}
|
||||
|
||||
/// <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)}], "
|
||||
+ $"defenses={this._defensesRemaining}, throne={this._occupier ?? "(none)"}, "
|
||||
+ $"switch217={this._switchHolders[217] ?? "-"}, switch218={this._switchHolders[218] ?? "-"}";
|
||||
=> $"CS state={this.State}({(int)this.State}), owner={this.OwnerGuildName ?? "(none)"}, "
|
||||
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds.Values)}], "
|
||||
+ $"defenses={this._defensesRemaining}, throne={this._occupierName ?? "(none)"}, "
|
||||
+ $"switch217={this.DescribeSwitch(217)}, switch218={this.DescribeSwitch(218)}";
|
||||
|
||||
private string DescribeSwitch(short switchNumber)
|
||||
=> this._switchHolders[switchNumber] is { } holder
|
||||
? (this._registeredGuilds.TryGetValue(holder, out var name) ? name : holder.ToString())
|
||||
: "-";
|
||||
|
||||
private void ClearBattleState()
|
||||
{
|
||||
@@ -379,17 +462,18 @@ public class CastleSiegeContext
|
||||
this._switchHolders[218] = null;
|
||||
this._defensesRemaining = 0;
|
||||
this._occupier = null;
|
||||
this._occupierName = null;
|
||||
this._crownHoldGuild = null;
|
||||
this._crownHoldStartUtc = null;
|
||||
this._lastShieldDown = false;
|
||||
}
|
||||
|
||||
private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
|
||||
private ValueTask TransitionAsync(CastleSiegeState state, DateTime now)
|
||||
{
|
||||
this.Phase = phase;
|
||||
this._phaseStartedUtc = now;
|
||||
this.State = state;
|
||||
this._stateStartedUtc = now;
|
||||
this._dirty = true;
|
||||
this.PhaseChanged?.Invoke(phase);
|
||||
this.StateChanged?.Invoke(state);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -414,5 +498,6 @@ public enum CrownEvent
|
||||
/// <param name="ShieldDown">Whether the crown shield is currently down (both switches held, defenses cleared).</param>
|
||||
/// <param name="ShieldChanged">Whether the shield state changed this tick (only then should the client be told).</param>
|
||||
/// <param name="Event">The event that occurred this tick.</param>
|
||||
/// <param name="Guild">The guild the event refers to, if any.</param>
|
||||
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild);
|
||||
/// <param name="GuildId">The guild the event refers to, if any.</param>
|
||||
/// <param name="GuildName">That guild's name, for display and client packets.</param>
|
||||
public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, Guid? GuildId, string? GuildName);
|
||||
|
||||
@@ -49,7 +49,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.Phase != CastleSiegePhase.Registration)
|
||||
if (context.State != CastleSiegeState.RegisterGuild)
|
||||
{
|
||||
await ShowAsync(player, "Castle Siege registration is not open right now.").ConfigureAwait(false);
|
||||
return;
|
||||
@@ -61,17 +61,15 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
var guildName = guildStatus.GuildId.ToString();
|
||||
if (player.GameContext is IGameServerContext serverContext)
|
||||
// Registrations are keyed on the guild's persistent id, so a later rename cannot detach them.
|
||||
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
|
||||
{
|
||||
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
|
||||
if (guild?.Name is { Length: > 0 } name)
|
||||
{
|
||||
guildName = name;
|
||||
}
|
||||
await ShowAsync(player, "Your guild could not be resolved. Please try again in a moment.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (context.RegisteredGuilds.Contains(guildName))
|
||||
var guildName = guild.Name;
|
||||
if (context.IsRegistered(guild.Id))
|
||||
{
|
||||
await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
|
||||
return;
|
||||
@@ -84,7 +82,7 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
context.RegisterGuild(guildName);
|
||||
context.RegisterGuild(guild.Id, guildName);
|
||||
await ShowAsync(player, fee > 0
|
||||
? $"Your guild '{guildName}' is registered for the Castle Siege. ({fee} zen paid)"
|
||||
: $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false);
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
// <copyright file="CastleSiegePhase.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 phases of a Castle Siege cycle.
|
||||
/// </summary>
|
||||
public enum CastleSiegePhase
|
||||
{
|
||||
/// <summary>Resting phase: castle is (un)owned, waiting for the next registration window.</summary>
|
||||
Ownership,
|
||||
|
||||
/// <summary>Guilds can register to attack.</summary>
|
||||
Registration,
|
||||
|
||||
/// <summary>Registration closed; defenders prepare before the siege starts.</summary>
|
||||
Preparation,
|
||||
|
||||
/// <summary>The siege battle is running.</summary>
|
||||
Siege,
|
||||
|
||||
/// <summary>Siege ended; determining the new owner.</summary>
|
||||
Settlement,
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
|
||||
// <copyright file="CastleSiegeSettings.cs" company="MUnique">
|
||||
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
|
||||
// </copyright>
|
||||
|
||||
@@ -7,15 +7,31 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
using MUnique.OpenMU.DataModel.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the Castle Siege cycle. Rides on the plugin custom-configuration system (no dedicated
|
||||
/// database table). A cycle runs: Ownership -> Registration -> Preparation -> Siege(war) -> Settlement, and
|
||||
/// auto-starts when the current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
|
||||
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute/second durations) are proxies over the
|
||||
/// runtime fields, which are hidden from the editor to keep the form clean.
|
||||
/// AdaMu operational settings for the Castle Siege cycle. Rides on the plugin custom-configuration system, so
|
||||
/// it is editable in the AdminPanel and needs no dedicated database table.
|
||||
/// <para>
|
||||
/// This is deliberately separate from <see cref="DataModel.Configuration.CastleSiegeConfiguration"/>, which is
|
||||
/// the upstream, database-backed configuration holding the NPC/zone/upgrade definitions and the crown hold
|
||||
/// time. Keeping AdaMu's operational knobs out of that entity means upstream schema changes apply cleanly and
|
||||
/// no hand-editing of the generated persistence code is needed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What lives where:
|
||||
/// <list type="bullet">
|
||||
/// <item>Castle owner and guild registrations: database (<c>CastleSiegeData</c>, <c>CastleSiegeGuildRegistration</c>).</item>
|
||||
/// <item>NPC/zone/upgrade definitions and crown hold time: database (<c>GameConfiguration.CastleSiegeConfiguration</c>).</item>
|
||||
/// <item>Cycle durations, registration fee, designated server and the current state: here.</item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the
|
||||
/// current day/time matches <see cref="OpenDays"/> + <see cref="RegistrationOpenTimes"/>.
|
||||
/// The AdminPanel-friendly properties (OpenDays checkboxes, minute durations) are proxies over the runtime
|
||||
/// fields, which are hidden from the editor to keep the form clean.
|
||||
/// </summary>
|
||||
public class CastleSiegeConfiguration
|
||||
public class CastleSiegeSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the days of week on which registration auto-opens (UTC). None = every day (still needs a
|
||||
@@ -42,7 +58,7 @@ public class CastleSiegeConfiguration
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the times of day (UTC) at which a new cycle opens registration.
|
||||
/// Empty = no auto-start (admins start cycles manually via the chat command).
|
||||
/// Empty = no auto-start (admins start cycles manually via the chat command or the AdminPanel).
|
||||
/// </summary>
|
||||
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
|
||||
|
||||
@@ -70,17 +86,6 @@ public class CastleSiegeConfiguration
|
||||
set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how long the guild master must hold the Crown to capture the throne, in seconds.
|
||||
/// The client shows a 60-second countdown, so 60 matches the on-screen timer.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public int CrownHoldSeconds
|
||||
{
|
||||
get => (int)this.CrownHoldDuration.TotalSeconds;
|
||||
set => this.CrownHoldDuration = TimeSpan.FromSeconds(Math.Max(1, value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
|
||||
/// for the siege. 0 disables the fee.
|
||||
@@ -112,29 +117,17 @@ public class CastleSiegeConfiguration
|
||||
[Browsable(false)]
|
||||
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>Gets or sets how long the guild master must hold the Crown to capture the throne.</summary>
|
||||
[Browsable(false)]
|
||||
public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
|
||||
// --- 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.
|
||||
|
||||
// --- Persisted runtime state (hidden from the AdminPanel) ---
|
||||
// These ride on the plugin's custom-configuration JSON (stored in PostgreSQL), so the castle owner and the
|
||||
// current cycle survive server restarts. Written by CastleSiegeEventPlugIn; restored on startup.
|
||||
|
||||
/// <summary>Gets or sets the persisted castle owner guild name (null = unowned).</summary>
|
||||
/// <summary>Gets or sets the persisted current state, so the cycle resumes after a restart.</summary>
|
||||
[Browsable(false)]
|
||||
public string? PersistedOwnerGuildName { get; set; }
|
||||
public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1;
|
||||
|
||||
/// <summary>Gets or sets the persisted current phase, so the cycle resumes after a restart.</summary>
|
||||
/// <summary>Gets or sets when the persisted state started (UTC), or null if never persisted.</summary>
|
||||
[Browsable(false)]
|
||||
public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership;
|
||||
|
||||
/// <summary>Gets or sets when the persisted phase started (UTC), or null if never persisted.</summary>
|
||||
[Browsable(false)]
|
||||
public DateTime? PersistedPhaseStartedUtc { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the persisted registered guild names for the current cycle.</summary>
|
||||
[Browsable(false)]
|
||||
public IList<string> PersistedRegisteredGuilds { get; set; } = new List<string>();
|
||||
public DateTime? PersistedStateStartedUtc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if <paramref name="now"/> (UTC) matches a scheduled registration-open day and falls
|
||||
@@ -43,35 +43,25 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
if (player.GuildStatus is not { } guildStatus)
|
||||
if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild)
|
||||
{
|
||||
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))
|
||||
if (!context.IsRegistered(guild.Id))
|
||||
{
|
||||
await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// The throne is taken by holding the Crown, not by talking here — give guidance based on the state.
|
||||
await ShowAsync(player, DescribeThroneStep(context, guildName)).ConfigureAwait(false);
|
||||
await ShowAsync(player, DescribeThroneStep(context, guild.Id)).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
|
||||
private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId)
|
||||
{
|
||||
if (context.Phase != CastleSiegePhase.Siege)
|
||||
if (!context.IsSiegeRunning)
|
||||
{
|
||||
return "The siege is not running yet.";
|
||||
}
|
||||
@@ -87,12 +77,12 @@ public class CastleSiegeThroneCaptureTalkPlugIn : IPlayerTalkToNpcPlugIn
|
||||
return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
|
||||
}
|
||||
|
||||
if (eligible == guildName)
|
||||
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 $"Guild '{eligible}' 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.";
|
||||
}
|
||||
|
||||
private static ValueTask ShowAsync(Player player, string text)
|
||||
|
||||
@@ -717,7 +717,7 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke
|
||||
// ADAMU-CUSTOM: Castle Siege PvP gate — true while the siege phase runs on Valley of Loren (map 30).
|
||||
private bool IsCastleSiegeBattleActive()
|
||||
=> this.CurrentMap?.Definition.Number == 30
|
||||
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.Phase == CastleSiege.CastleSiegePhase.Siege;
|
||||
&& PlugIns.PeriodicTasks.CastleSiegeEventPlugIn.TryGetContext(this.GameContext)?.IsSiegeRunning == true;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async ValueTask<HitInfo?> AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
|
||||
|
||||
@@ -30,9 +30,11 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
|
||||
public async ValueTask HandleCommandAsync(Player player, string command)
|
||||
{
|
||||
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length < 2 || !Enum.TryParse<CastleSiegePhase>(parts[1], true, out var phase))
|
||||
// The cycle uses the original Season 6 state names the client knows. AdaMu drives only this subset;
|
||||
// Idle2, RegisterMark, Idle3 and Notify exist for client compatibility but are never entered.
|
||||
if (parts.Length < 2 || !Enum.TryParse<CastleSiegeState>(parts[1], true, out var phase))
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Ownership|Registration|Preparation|Siege|Settlement>", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Idle1|RegisterGuild|Ready|Start|End|EndCycle>", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -43,7 +45,7 @@ public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
|
||||
await context.ForceStateAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,22 @@ public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
|
||||
return;
|
||||
}
|
||||
|
||||
context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner);
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(owner))
|
||||
{
|
||||
context.SetOwner(null, null);
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege owner cleared.", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Ownership is stored by the guild's persistent id, so the name given here is resolved once.
|
||||
var guildId = await CastleSiegeEventPlugIn.ResolveGuildIdByNameAsync(player.GameContext, owner).ConfigureAwait(false);
|
||||
if (guildId is not { } id)
|
||||
{
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"No guild named '{owner}' was found.", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
return;
|
||||
}
|
||||
|
||||
context.SetOwner(id, owner);
|
||||
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner}.", MessageType.BlueNormal)).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using MUnique.OpenMU.DataModel.Configuration;
|
||||
using MUnique.OpenMU.GameLogic.CastleSiege;
|
||||
using MUnique.OpenMU.GameLogic.NPC;
|
||||
using MUnique.OpenMU.GameLogic.Views;
|
||||
@@ -15,65 +16,50 @@ using MUnique.OpenMU.Interfaces;
|
||||
using MUnique.OpenMU.Pathfinding;
|
||||
using MUnique.OpenMU.Persistence;
|
||||
using MUnique.OpenMU.PlugIns;
|
||||
using CastleSiegeDefinition = MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration.
|
||||
/// State is per-<see cref="IGameContext"/> and kept in memory (P1: no persistence).
|
||||
/// When the siege phase starts (P3), warps registered guild members to the Valley of Loren battle map.
|
||||
/// Drives the Castle Siege state machine: ticks it every second and carries its operational settings.
|
||||
/// <para>
|
||||
/// The cycle uses the original Season 6 state numbers the client expects. The castle owner and the guild
|
||||
/// registrations are persisted in real database tables (<see cref="CastleSiegeData"/> and
|
||||
/// <see cref="CastleSiegeGuildRegistration"/>) and keyed by the guild's persistent <see cref="Guid"/>, so a
|
||||
/// guild rename can no longer move castle ownership to the wrong guild. Only the current state and when it
|
||||
/// started ride on the plugin's custom-configuration JSON.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Castle NPCs (gates, statues, catapults, the crown and its switches) are read from
|
||||
/// <see cref="GameConfiguration.CastleSiegeConfiguration"/>, which the CastleSiegeInitializer seeds, instead
|
||||
/// of being hard-coded here.
|
||||
/// </para>
|
||||
/// When the siege starts, registered guild members are warped to the Valley of Loren battle map.
|
||||
/// </summary>
|
||||
[PlugIn]
|
||||
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (phase state machine, scheduling, siege warp).")]
|
||||
[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (state machine, scheduling, siege warp).")]
|
||||
[Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")]
|
||||
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
|
||||
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeSettings>, ISupportDefaultCustomConfiguration
|
||||
{
|
||||
private const ushort ValleyOfLorenMapNumber = 30;
|
||||
private const short CastleGateNumber = 277; // client MONSTER_CASTLE_GATE1 -> renders a real gate + blocks terrain until broken
|
||||
private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for 277
|
||||
private const int SwitchHoldRange = 3;
|
||||
|
||||
// Castle defenses spawned at siege start — the 6 REAL castle gates (client g_byGateLocation).
|
||||
// The client renders them closed and blocks the terrain until each is broken. All must fall before the throne.
|
||||
private static readonly (short Number, byte X, byte Y)[] DefenseSpawns =
|
||||
{
|
||||
(CastleGateNumber, 67, 114),
|
||||
(CastleGateNumber, 93, 114),
|
||||
(CastleGateNumber, 119, 114),
|
||||
(CastleGateNumber, 81, 161),
|
||||
(CastleGateNumber, 107, 161),
|
||||
(CastleGateNumber, 93, 204),
|
||||
};
|
||||
|
||||
// Siege weapons spawned at siege start purely for war atmosphere. The client renders monster 221/222 as
|
||||
// catapults (attacker/defender). They are NOT counted as defenses (destroying them doesn't open the throne).
|
||||
private const short GateTemplateNumber = 131; // BloodCastle "Castle Gate" destructible (has HP) — HP template for the CS gates.
|
||||
private const short CrownNumber = 216;
|
||||
private const short CatapultAttackNumber = 221; // client MONSTER_SLINGSHOT_ATTACK
|
||||
private const short CatapultDefenseNumber = 222; // client MONSTER_SLINGSHOT_DEFENSE
|
||||
private static readonly (short Number, byte X, byte Y)[] CatapultSpawns =
|
||||
{
|
||||
(CatapultDefenseNumber, 80, 140),
|
||||
(CatapultDefenseNumber, 110, 140),
|
||||
(CatapultDefenseNumber, 93, 178),
|
||||
(CatapultAttackNumber, 74, 100),
|
||||
(CatapultAttackNumber, 112, 100),
|
||||
};
|
||||
|
||||
// Crown Switch positions on Valley of Loren (from the map init) — held by standing on them: (number, x, y).
|
||||
private static readonly (short SwitchNumber, byte X, byte Y)[] SwitchPositions =
|
||||
{
|
||||
(217, 167, 194),
|
||||
(218, 184, 195),
|
||||
};
|
||||
|
||||
// The Crown (NPC 216) position on Valley of Loren — the guild master holds it here to capture the throne.
|
||||
private static readonly Point CrownPosition = new(176, 212);
|
||||
private const int SwitchHoldRange = 3;
|
||||
private const int CrownHoldRange = 4;
|
||||
|
||||
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
|
||||
|
||||
private string? _cachedFlagOwner;
|
||||
/// <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.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<uint, Guid> PersistentGuildIds = new();
|
||||
|
||||
private Guid? _cachedFlagOwner;
|
||||
private byte[]? _cachedFlagLogo;
|
||||
|
||||
/// <inheritdoc />
|
||||
public CastleSiegeConfiguration? Configuration { get; set; }
|
||||
public CastleSiegeSettings? Configuration { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it.
|
||||
@@ -99,41 +85,80 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
return serverContext.Id == context.Configuration.CastleSiegeServerId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the persistent identifier of the player's guild, or <see langword="null"/> when the player is
|
||||
/// not in a guild or the guild cannot be resolved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Interfaces.Guild"/> deliberately carries no id: the guild server assigns short ids in memory
|
||||
/// only. The persistent <see cref="Guid"/> is therefore resolved through the guild name and cached, which
|
||||
/// avoids adding a method to <see cref="IGuildServer"/> that upstream would keep changing.
|
||||
/// </remarks>
|
||||
/// <param name="player">The player.</param>
|
||||
public static async ValueTask<(Guid Id, string Name)?> GetPersistentGuildAsync(Player player)
|
||||
{
|
||||
if (player.GuildStatus is not { } guildStatus
|
||||
|| player.GameContext is not IGameServerContext serverContext)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
|
||||
if (guild?.Name is not { Length: > 0 } name)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (PersistentGuildIds.TryGetValue(guildStatus.GuildId, out var cached))
|
||||
{
|
||||
return (cached, name);
|
||||
}
|
||||
|
||||
var resolved = await ResolveGuildIdByNameAsync(player.GameContext, name).ConfigureAwait(false);
|
||||
if (resolved is not { } id)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
PersistentGuildIds[guildStatus.GuildId] = id;
|
||||
return (id, name);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public object CreateDefaultConfig() => new CastleSiegeConfiguration();
|
||||
public object CreateDefaultConfig() => new CastleSiegeSettings();
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
|
||||
{
|
||||
var context = Contexts.GetOrAdd(gameContext, gc =>
|
||||
{
|
||||
var config = this.Configuration ?? new CastleSiegeConfiguration();
|
||||
var created = new CastleSiegeContext(config);
|
||||
var settings = this.Configuration ?? new CastleSiegeSettings();
|
||||
var created = new CastleSiegeContext(settings);
|
||||
|
||||
// Restore state persisted before the last restart (owner/phase/registrations) BEFORE subscribing,
|
||||
// so restoring doesn't announce phases or re-spawn defenses.
|
||||
created.RestoreState(config.PersistedOwnerGuildName, config.PersistedPhase, config.PersistedPhaseStartedUtc, config.PersistedRegisteredGuilds);
|
||||
// Restore the cycle bookkeeping BEFORE subscribing, so restoring doesn't announce states or
|
||||
// re-spawn defenses. The owner and registrations are loaded from the database right after.
|
||||
created.RestoreState(null, null, settings.PersistedState, settings.PersistedStateStartedUtc, null);
|
||||
_ = LoadPersistedStateAsync(gc, created);
|
||||
|
||||
// Announce phase changes to the whole server and, when the siege begins, warp registered members.
|
||||
created.PhaseChanged += phase => _ = OnPhaseChangedAsync(gc, created, phase);
|
||||
created.StateChanged += state => _ = OnStateChangedAsync(gc, created, state);
|
||||
|
||||
return created;
|
||||
});
|
||||
|
||||
// Point the context at the current (possibly AdminPanel-edited) config so schedule/durations are live.
|
||||
if (this.Configuration is { } liveConfig)
|
||||
// Point the context at the current (possibly AdminPanel-edited) settings so schedule/durations are live.
|
||||
if (this.Configuration is { } liveSettings)
|
||||
{
|
||||
context.UpdateConfiguration(liveConfig);
|
||||
context.UpdateConfiguration(liveSettings);
|
||||
}
|
||||
|
||||
// With multiple game servers each has its own map instances, so the siege must run on ONE designated
|
||||
// server (CastleSiegeServerId). Other servers skip the siege entirely — they only mirror the shared
|
||||
// castle owner from the config so the hunting-map gate + castle flag rewards still work everywhere.
|
||||
// castle owner from the database so the hunting-map gate + castle flag rewards still work everywhere.
|
||||
if (!IsCastleSiegeServer(gameContext))
|
||||
{
|
||||
context.SyncOwnerFromConfig();
|
||||
if (DateTime.UtcNow.Second % 15 == 0)
|
||||
{
|
||||
await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
|
||||
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -143,18 +168,18 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false);
|
||||
|
||||
// During the siege, evaluate the Crown Switches (held by standing on them) and the throne capture every tick.
|
||||
if (context.Phase == CastleSiegePhase.Siege)
|
||||
if (context.IsSiegeRunning)
|
||||
{
|
||||
await ProcessSiegeTickAsync(gameContext, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Persist owner/phase/registrations to the database whenever they changed, so they survive a restart.
|
||||
// Persist owner/state/registrations whenever they changed, so they survive a restart.
|
||||
if (context.ConsumeDirty())
|
||||
{
|
||||
await this.PersistStateAsync(gameContext, context).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any phase).
|
||||
// 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)
|
||||
{
|
||||
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
|
||||
@@ -171,25 +196,95 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase)
|
||||
/// <summary>Gets the seeded Castle Siege definition, or <see langword="null"/> when it was not initialized.</summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
private static CastleSiegeDefinition? GetDefinition(IGameContext gameContext)
|
||||
=> gameContext.Configuration.CastleSiegeConfiguration;
|
||||
|
||||
/// <summary>Resolves a guild's persistent identifier from its name, or null when there is no such guild.</summary>
|
||||
/// <param name="gameContext">The game context.</param>
|
||||
/// <param name="guildName">The guild name.</param>
|
||||
internal static async ValueTask<Guid?> ResolveGuildIdByNameAsync(IGameContext gameContext, string guildName)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (phase)
|
||||
using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration);
|
||||
var guilds = await context.GetAsync<DataModel.Entities.Guild>().ConfigureAwait(false);
|
||||
return guilds.FirstOrDefault(guild => guild.Name == guildName)?.Id;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
case CastleSiegePhase.Registration:
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: could not resolve the persistent id of guild '{guildName}'.", guildName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<string?> ResolveGuildNameByIdAsync(IGameContext gameContext, Guid guildId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration);
|
||||
var guild = await context.GetByIdAsync<DataModel.Entities.Guild>(guildId).ConfigureAwait(false);
|
||||
return guild?.Name;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: could not resolve the name of guild {guildId}.", guildId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Loads the persisted castle owner and guild registrations from the database into the context.</summary>
|
||||
private static async ValueTask LoadPersistedStateAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration);
|
||||
var data = (await dataContext.GetAsync<CastleSiegeData>().ConfigureAwait(false)).FirstOrDefault();
|
||||
|
||||
Guid? ownerId = data?.IsOccupied == true ? data.OwnerGuildId : null;
|
||||
var ownerName = ownerId is { } id ? await ResolveGuildNameByIdAsync(gameContext, id).ConfigureAwait(false) : null;
|
||||
|
||||
using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration);
|
||||
var registrations = await registrationContext.GetAsync<CastleSiegeGuildRegistration>().ConfigureAwait(false);
|
||||
|
||||
var restored = new List<KeyValuePair<Guid, string>>();
|
||||
foreach (var registration in registrations)
|
||||
{
|
||||
var name = await ResolveGuildNameByIdAsync(gameContext, registration.GuildId).ConfigureAwait(false);
|
||||
restored.Add(new KeyValuePair<Guid, string>(registration.GuildId, name ?? registration.GuildId.ToString()));
|
||||
}
|
||||
|
||||
context.RestoreState(ownerId, ownerName, context.State, context.StateStartedUtc, restored);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error while loading the persisted state.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task OnStateChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegeState state)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (state)
|
||||
{
|
||||
case CastleSiegeState.RegisterGuild:
|
||||
await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false);
|
||||
break;
|
||||
case CastleSiegePhase.Siege:
|
||||
case CastleSiegeState.Start:
|
||||
await AnnounceAsync(gameContext, "The Castle Siege has begun! Break the castle gates and guardian statues, then hold BOTH Crown Switches to take the throne!").ConfigureAwait(false);
|
||||
await SpawnCastleDefensesAsync(gameContext, context).ConfigureAwait(false);
|
||||
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
|
||||
break;
|
||||
case CastleSiegePhase.Settlement:
|
||||
case CastleSiegeState.End:
|
||||
// Stop the on-map countdown for everyone still on the battle map.
|
||||
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
|
||||
break;
|
||||
case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner:
|
||||
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);
|
||||
break;
|
||||
default:
|
||||
@@ -199,52 +294,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error handling phase change to {phase}.", phase);
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (this.Configuration is not { } config)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Snapshot the live context into the persisted config fields.
|
||||
config.PersistedOwnerGuildName = context.OwnerGuildName;
|
||||
config.PersistedPhase = context.Phase;
|
||||
config.PersistedPhaseStartedUtc = context.PhaseStartedUtc;
|
||||
config.PersistedRegisteredGuilds = context.RegisteredGuilds.ToList();
|
||||
|
||||
// Find our plugin-configuration row via the in-memory config graph to get its id.
|
||||
var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID;
|
||||
var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId);
|
||||
if (inMemory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Load a fresh, change-tracked copy of that row in its own (non-caching) context, rewrite its
|
||||
// custom-configuration JSON, and save — this is what actually persists to PostgreSQL.
|
||||
using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration);
|
||||
var row = await ctx.GetByIdAsync<PlugInConfiguration>(inMemory.GetId()).ConfigureAwait(false);
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler);
|
||||
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogInformation("Castle Siege: persisted state (owner={owner}, phase={phase}).", config.PersistedOwnerGuildName ?? "(none)", config.PersistedPhase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error while persisting state to the database.");
|
||||
.LogError(ex, "Castle Siege: error handling state change to {state}.", state);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -269,52 +319,36 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spawns the castle defenses from the seeded NPC definitions. Definitions flagged
|
||||
/// <see cref="CastleSiegeNpcDefinition.IsPersistedToDatabase"/> are the breakable defenses (gates and
|
||||
/// guardian statues) and are counted towards the throne; the catapults are pure war atmosphere.
|
||||
/// </summary>
|
||||
private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (gameContext is not GameContext concrete)
|
||||
if (gameContext is not GameContext concrete
|
||||
|| GetDefinition(gameContext) is not { } definition
|
||||
|| await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
|
||||
{
|
||||
context.SetDefenseCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
|
||||
if (map is null)
|
||||
{
|
||||
context.SetDefenseCount(0);
|
||||
return;
|
||||
}
|
||||
|
||||
var spawned = 0;
|
||||
for (var i = 0; i < DefenseSpawns.Length; i++)
|
||||
{
|
||||
var spawn = DefenseSpawns[i];
|
||||
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number);
|
||||
if (definition is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// The stock CS gate (277) definition has no HP and isn't destructible; make it breakable
|
||||
// by borrowing the HP/attributes of a working destructible (BloodCastle gate 131).
|
||||
var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
|
||||
EnsureDestructible(definition, template);
|
||||
|
||||
var spawnArea = new MonsterSpawnArea
|
||||
var spawned = 0;
|
||||
var index = 0;
|
||||
foreach (var npc in definition.NpcDefinitions.Where(n => n.IsPersistedToDatabase && n.MonsterDefinition is not null))
|
||||
{
|
||||
MonsterDefinition = definition,
|
||||
Quantity = 1,
|
||||
X1 = spawn.X,
|
||||
X2 = spawn.X,
|
||||
Y1 = spawn.Y,
|
||||
Y2 = spawn.Y,
|
||||
Direction = Direction.South,
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
// The stock CS gate/statue definitions have no HP and aren't destructible; make them breakable
|
||||
// by borrowing the HP/attributes of a working destructible (BloodCastle gate 131).
|
||||
EnsureDestructible(npc.MonsterDefinition!, template);
|
||||
|
||||
var npc = await concrete.MapInitializer.InitializeSpawnAsync(1000 + i, map, spawnArea).ConfigureAwait(false);
|
||||
if (npc is AttackableNpcBase attackable)
|
||||
var monster = await concrete.MapInitializer
|
||||
.InitializeSpawnAsync(1000 + index++, map, CreateSpawnArea(npc))
|
||||
.ConfigureAwait(false);
|
||||
if (monster is AttackableNpcBase attackable)
|
||||
{
|
||||
attackable.Died += (_, _) => context.NotifyDefenseDestroyed();
|
||||
spawned++;
|
||||
@@ -323,29 +357,12 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
|
||||
context.SetDefenseCount(spawned);
|
||||
|
||||
// Spawn the catapults (siege weapons) for war atmosphere — not counted as defenses.
|
||||
for (var i = 0; i < CatapultSpawns.Length; i++)
|
||||
index = 0;
|
||||
foreach (var npc in definition.NpcDefinitions.Where(IsCatapult))
|
||||
{
|
||||
var spawn = CatapultSpawns[i];
|
||||
var definition = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == spawn.Number);
|
||||
if (definition is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var spawnArea = new MonsterSpawnArea
|
||||
{
|
||||
MonsterDefinition = definition,
|
||||
Quantity = 1,
|
||||
X1 = spawn.X,
|
||||
X2 = spawn.X,
|
||||
Y1 = spawn.Y,
|
||||
Y2 = spawn.Y,
|
||||
Direction = Direction.South,
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
|
||||
await concrete.MapInitializer.InitializeSpawnAsync(2000 + i, map, spawnArea).ConfigureAwait(false);
|
||||
await concrete.MapInitializer
|
||||
.InitializeSpawnAsync(2000 + index++, map, CreateSpawnArea(npc))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -356,27 +373,53 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsCatapult(CastleSiegeNpcDefinition npc)
|
||||
=> npc.MonsterDefinition is { } monster
|
||||
&& (monster.Number == CatapultAttackNumber || monster.Number == CatapultDefenseNumber);
|
||||
|
||||
private static MonsterSpawnArea CreateSpawnArea(CastleSiegeNpcDefinition npc) => new()
|
||||
{
|
||||
MonsterDefinition = npc.MonsterDefinition,
|
||||
Quantity = 1,
|
||||
X1 = npc.SpawnX,
|
||||
X2 = npc.SpawnX,
|
||||
Y1 = npc.SpawnY,
|
||||
Y2 = npc.SpawnY,
|
||||
Direction = npc.Direction,
|
||||
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
|
||||
};
|
||||
|
||||
private static Point? GetNpcPosition(IGameContext gameContext, short monsterNumber)
|
||||
{
|
||||
var npc = GetDefinition(gameContext)?.NpcDefinitions
|
||||
.FirstOrDefault(n => n.MonsterDefinition?.Number == monsterNumber);
|
||||
return npc is null ? null : new Point(npc.SpawnX, npc.SpawnY);
|
||||
}
|
||||
|
||||
private static async Task ProcessSiegeTickAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
|
||||
if (map is null)
|
||||
if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Each Crown Switch is held by whichever registered guild currently has a member standing on it.
|
||||
foreach (var (switchNumber, x, y) in SwitchPositions)
|
||||
foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
|
||||
{
|
||||
string? holder = null;
|
||||
var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType<Player>();
|
||||
foreach (var player in nearby)
|
||||
if (GetNpcPosition(gameContext, switchNumber) is not { } position)
|
||||
{
|
||||
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
|
||||
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
|
||||
continue;
|
||||
}
|
||||
|
||||
Guid? holder = null;
|
||||
foreach (var player in map.GetAttackablesInRange(position, SwitchHoldRange).OfType<Player>())
|
||||
{
|
||||
holder = guildName;
|
||||
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild
|
||||
&& context.IsRegistered(guild.Id))
|
||||
{
|
||||
holder = guild.Id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -386,24 +429,31 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
// 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).
|
||||
// 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.
|
||||
var eligible = context.GetShieldEligibleGuild();
|
||||
Player? masterPlayer = null;
|
||||
if (eligible is not null)
|
||||
string? eligibleName = null;
|
||||
if (eligible is { } eligibleId && GetNpcPosition(gameContext, CrownNumber) is { } crownPosition)
|
||||
{
|
||||
foreach (var player in map.GetAttackablesInRange(CrownPosition, CrownHoldRange).OfType<Player>())
|
||||
foreach (var player in map.GetAttackablesInRange(crownPosition, CrownHoldRange).OfType<Player>())
|
||||
{
|
||||
if (player.GuildStatus?.Position == GuildPosition.GuildMaster
|
||||
&& await GetGuildNameAsync(player).ConfigureAwait(false) == eligible)
|
||||
if (player.GuildStatus?.Position != GuildPosition.GuildMaster)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild && guild.Id == eligibleId)
|
||||
{
|
||||
masterPlayer = player;
|
||||
eligibleName = guild.Name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var crown = context.TickCrownHold(eligible, masterPlayer is not null, now, context.Configuration.CrownHoldDuration);
|
||||
var holdDuration = TimeSpan.FromSeconds(GetDefinition(gameContext)?.CrownHoldTimeSeconds ?? 60);
|
||||
var crown = context.TickCrownHold(eligible, eligibleName, masterPlayer is not null, now, holdDuration);
|
||||
|
||||
// Shield drop/raise -> everyone on the battle map, but only when it flips (the packet pops a modal).
|
||||
if (crown.ShieldChanged)
|
||||
@@ -414,13 +464,13 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
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.
|
||||
// The registration panel is shown ONLY to the master taking the crown.
|
||||
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);
|
||||
break;
|
||||
case CrownEvent.Captured when crown.Guild is { } captured:
|
||||
case CrownEvent.Captured when crown.GuildName 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;
|
||||
@@ -430,7 +480,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
|
||||
// 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.PhaseStartedUtc).TotalSeconds % 10 == 0)
|
||||
if ((int)(now - context.StateStartedUtc).TotalSeconds % 10 == 0)
|
||||
{
|
||||
var remaining = context.GetRemainingSiegeTime(now);
|
||||
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
|
||||
@@ -470,16 +520,131 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
? player.InvokeViewPlugInAsync<ICastleSiegeStatusViewPlugIn>(action).AsTask()
|
||||
: Task.CompletedTask);
|
||||
|
||||
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context.OwnerGuildName is not { Length: > 0 } owner)
|
||||
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
|
||||
if (map?.SafeZoneSpawnGate is not { } gate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var logo = await this.GetOwnerLogoAsync(gameContext, owner).ConfigureAwait(false);
|
||||
await gameContext.ForEachPlayerAsync(async player =>
|
||||
{
|
||||
if (await GetPersistentGuildAsync(player).ConfigureAwait(false) is { } guild
|
||||
&& context.IsRegistered(guild.Id))
|
||||
{
|
||||
await player.WarpToAsync(gate).ConfigureAwait(false);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the castle owner and the guild registrations to their database tables, and the cycle
|
||||
/// bookkeeping (current state and when it started) to the plugin's custom-configuration JSON.
|
||||
/// </summary>
|
||||
private async ValueTask PersistStateAsync(GameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await this.PersistOwnerAsync(gameContext, context).ConfigureAwait(false);
|
||||
await PersistRegistrationsAsync(gameContext, context).ConfigureAwait(false);
|
||||
await this.PersistCycleBookkeepingAsync(gameContext, context).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error while persisting state to the database.");
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask PersistOwnerAsync(GameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
using var dataContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeData), false, gameContext.Configuration);
|
||||
var data = (await dataContext.GetAsync<CastleSiegeData>().ConfigureAwait(false)).FirstOrDefault()
|
||||
?? dataContext.CreateNew<CastleSiegeData>();
|
||||
|
||||
data.OwnerGuildId = context.OwnerGuildId;
|
||||
data.IsOccupied = context.OwnerGuildId is not null;
|
||||
await dataContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogInformation(
|
||||
"Castle Siege: persisted owner={owner} state={state}.",
|
||||
context.OwnerGuildName ?? "(none)",
|
||||
context.State);
|
||||
}
|
||||
|
||||
private static async ValueTask PersistRegistrationsAsync(GameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
using var registrationContext = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CastleSiegeGuildRegistration), false, gameContext.Configuration);
|
||||
var existing = (await registrationContext.GetAsync<CastleSiegeGuildRegistration>().ConfigureAwait(false)).ToList();
|
||||
var wanted = context.RegisteredGuildIds.ToHashSet();
|
||||
|
||||
foreach (var registration in existing)
|
||||
{
|
||||
if (!wanted.Remove(registration.GuildId))
|
||||
{
|
||||
await registrationContext.DeleteAsync(registration).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var guildId in wanted)
|
||||
{
|
||||
var registration = registrationContext.CreateNew<CastleSiegeGuildRegistration>();
|
||||
registration.GuildId = guildId;
|
||||
}
|
||||
|
||||
await registrationContext.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask PersistCycleBookkeepingAsync(GameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
if (this.Configuration is not { } settings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
settings.PersistedState = context.State;
|
||||
settings.PersistedStateStartedUtc = context.StateStartedUtc;
|
||||
|
||||
// Find our plugin-configuration row via the in-memory config graph to get its id, then rewrite its
|
||||
// custom-configuration JSON in its own (non-caching) context.
|
||||
var pluginTypeId = typeof(CastleSiegeEventPlugIn).GUID;
|
||||
var inMemory = gameContext.Configuration.PlugInConfigurations.FirstOrDefault(c => c.TypeId == pluginTypeId);
|
||||
if (inMemory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var ctx = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(PlugInConfiguration), false, gameContext.Configuration);
|
||||
var row = await ctx.GetByIdAsync<PlugInConfiguration>(inMemory.GetId()).ConfigureAwait(false);
|
||||
if (row is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
row.SetConfiguration(settings, gameContext.PlugInManager.CustomConfigReferenceHandler);
|
||||
await ctx.SaveChangesAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async ValueTask BroadcastCastleFlagAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context.OwnerGuildId is not { } owner)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var logo = await this.GetOwnerLogoAsync(gameContext, owner, context.OwnerGuildName).ConfigureAwait(false);
|
||||
if (logo is null)
|
||||
{
|
||||
return;
|
||||
@@ -500,77 +665,32 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask<byte[]?> GetOwnerLogoAsync(IGameContext gameContext, string ownerName)
|
||||
private async ValueTask<byte[]?> GetOwnerLogoAsync(IGameContext gameContext, Guid ownerId, string? ownerName)
|
||||
{
|
||||
if (this._cachedFlagOwner == ownerName && this._cachedFlagLogo is not null)
|
||||
if (this._cachedFlagOwner == ownerId && this._cachedFlagLogo is not null)
|
||||
{
|
||||
return this._cachedFlagLogo;
|
||||
}
|
||||
|
||||
if (gameContext is not IGameServerContext serverContext)
|
||||
if (gameContext is not IGameServerContext serverContext || ownerName is not { Length: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var guildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
|
||||
if (guildId == 0)
|
||||
var shortGuildId = await serverContext.GuildServer.GetGuildIdByNameAsync(ownerName).ConfigureAwait(false);
|
||||
if (shortGuildId == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var guild = await serverContext.GuildServer.GetGuildAsync(guildId).ConfigureAwait(false);
|
||||
var guild = await serverContext.GuildServer.GetGuildAsync(shortGuildId).ConfigureAwait(false);
|
||||
if (guild?.Logo is not { Length: > 0 } logo)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
this._cachedFlagOwner = ownerName;
|
||||
this._cachedFlagOwner = ownerId;
|
||||
this._cachedFlagLogo = logo;
|
||||
return logo;
|
||||
}
|
||||
|
||||
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
|
||||
if (map?.SafeZoneSpawnGate is not { } gate)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await gameContext.ForEachPlayerAsync(async player =>
|
||||
{
|
||||
var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
|
||||
if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
|
||||
{
|
||||
await player.WarpToAsync(gate).ConfigureAwait(false);
|
||||
}
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
|
||||
.LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
|
||||
}
|
||||
}
|
||||
|
||||
private static async ValueTask<string?> GetGuildNameAsync(Player player)
|
||||
{
|
||||
if (player.GuildStatus is not { } guildStatus)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (player.GameContext is IGameServerContext serverContext)
|
||||
{
|
||||
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
|
||||
if (guild?.Name is { Length: > 0 } name)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
}
|
||||
|
||||
return guildStatus.GuildId.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user