diff --git a/src/DataModel/Configuration/CastleSiegeConfiguration.cs b/src/DataModel/Configuration/CastleSiegeConfiguration.cs
new file mode 100644
index 0000000..595d673
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeConfiguration.cs
@@ -0,0 +1,152 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+using MUnique.OpenMU.DataModel.Configuration.Items;
+
+///
+/// Main configuration for the castle siege event.
+///
+[Cloneable]
+public partial class CastleSiegeConfiguration
+{
+ ///
+ /// Gets or sets a value indicating whether the castle siege feature is enabled.
+ ///
+ public bool Enabled { get; set; }
+
+ ///
+ /// Gets or sets the number of seconds a guild must hold the crown to capture the castle.
+ ///
+ public int CrownHoldTimeSeconds { get; set; } = 30;
+
+ ///
+ /// Gets or sets the minimum combined level of a guild master required to register for the siege.
+ ///
+ public int RegisterMinLevel { get; set; } = 200;
+
+ ///
+ /// Gets or sets the minimum number of guild members required to register for the siege.
+ ///
+ public int RegisterMinMembers { get; set; } = 20;
+
+ ///
+ /// Gets or sets the minimum number of seconds a participant must be present in the battle to be eligible for a reward.
+ ///
+ public int ParticipantRewardMinSeconds { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of attacking alliance slots.
+ ///
+ public int MaxAttackingGuilds { get; set; } = 3;
+
+ ///
+ /// Gets or sets the guild score awarded to the guild that wins the siege.
+ ///
+ public int GuildScoreCastleSiege { get; set; }
+
+ ///
+ /// Gets or sets the guild score awarded to alliance member guilds of the winning side.
+ ///
+ public int GuildScoreCastleSiegeMembers { get; set; }
+
+ ///
+ /// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed gate.
+ ///
+ public int GateBuyPrice { get; set; }
+
+ ///
+ /// Gets or sets the Zen cost for the castle owner to re-purchase a destroyed statue.
+ ///
+ public int StatueBuyPrice { get; set; }
+
+ ///
+ /// Gets or sets the map definition for the Valley of Loren (map 30), where the siege takes place.
+ ///
+ public virtual GameMapDefinition? CastleSiegeMapDefinition { get; set; }
+
+ ///
+ /// Gets or sets the map definition for the Land of Trials (map 31), the castle-owner's exclusive zone.
+ ///
+ public virtual GameMapDefinition? LandOfTrialsMapDefinition { get; set; }
+
+ ///
+ /// Gets or sets the item definition for the participation reward item.
+ ///
+ public virtual ItemDefinition? RewardItemDefinition { get; set; }
+
+ ///
+ /// Gets or sets the schedule entries that define when each siege state begins.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection StateSchedule { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the definitions for all castle siege NPCs (gates, statues, etc.).
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection NpcDefinitions { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the upgrade levels for gate defense.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection GateDefenseUpgrades { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the upgrade levels for gate maximum HP.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection GateLifeUpgrades { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the upgrade levels for statue defense.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection StatueDefenseUpgrades { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the upgrade levels for statue maximum HP.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection StatueLifeUpgrades { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the upgrade levels for statue HP regeneration.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection StatueRegenUpgrades { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the zones on the siege map where attacking siege machines may be placed.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection AttackMachineZones { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the zones on the siege map where defensive siege machines may be placed.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection DefenseMachineZones { get; protected set; } = null!;
+
+ ///
+ /// Gets or sets the zone where defending players respawn during the siege.
+ ///
+ [MemberOfAggregate]
+ public virtual CastleSiegeZoneDefinition? DefenseRespawnArea { get; set; }
+
+ ///
+ /// Gets or sets the zone where attacking players respawn during the siege.
+ ///
+ [MemberOfAggregate]
+ public virtual CastleSiegeZoneDefinition? AttackRespawnArea { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return "Castle Siege Configuration";
+ }
+}
diff --git a/src/DataModel/Configuration/CastleSiegeJoinSide.cs b/src/DataModel/Configuration/CastleSiegeJoinSide.cs
new file mode 100644
index 0000000..bae4d83
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeJoinSide.cs
@@ -0,0 +1,36 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// Defines the side (defending or attacking) a guild or NPC belongs to in the castle siege.
+///
+public enum CastleSiegeJoinSide : byte
+{
+ ///
+ /// No side assigned.
+ ///
+ None = 0,
+
+ ///
+ /// The defending guild side.
+ ///
+ Defense = 1,
+
+ ///
+ /// The first attacking alliance slot.
+ ///
+ Attack1 = 2,
+
+ ///
+ /// The second attacking alliance slot.
+ ///
+ Attack2 = 3,
+
+ ///
+ /// The third attacking alliance slot.
+ ///
+ Attack3 = 4,
+}
diff --git a/src/DataModel/Configuration/CastleSiegeNpcDefinition.cs b/src/DataModel/Configuration/CastleSiegeNpcDefinition.cs
new file mode 100644
index 0000000..c1d6d78
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeNpcDefinition.cs
@@ -0,0 +1,55 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+
+///
+/// Defines a castle siege NPC instance, including its spawn location, side, and persistence settings.
+///
+[Cloneable]
+public partial class CastleSiegeNpcDefinition
+{
+ ///
+ /// Gets or sets the monster definition template for this NPC.
+ ///
+ public virtual MonsterDefinition? MonsterDefinition { get; set; }
+
+ ///
+ /// Gets or sets the unique instance identifier within its NPC type.
+ ///
+ public byte InstanceId { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether this NPC's state is persisted to the database between sieges.
+ ///
+ public bool IsPersistedToDatabase { get; set; }
+
+ ///
+ /// Gets or sets the default join side this NPC belongs to.
+ ///
+ public CastleSiegeJoinSide DefaultSide { get; set; }
+
+ ///
+ /// Gets or sets the X coordinate of the NPC's spawn position.
+ ///
+ public byte SpawnX { get; set; }
+
+ ///
+ /// Gets or sets the Y coordinate of the NPC's spawn position.
+ ///
+ public byte SpawnY { get; set; }
+
+ ///
+ /// Gets or sets the facing direction of the NPC at spawn.
+ ///
+ public Direction Direction { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.MonsterDefinition} #{this.InstanceId} at ({this.SpawnX},{this.SpawnY})";
+ }
+}
diff --git a/src/DataModel/Configuration/CastleSiegeState.cs b/src/DataModel/Configuration/CastleSiegeState.cs
new file mode 100644
index 0000000..82b5a24
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeState.cs
@@ -0,0 +1,61 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// The state of the castle siege event cycle.
+///
+public enum CastleSiegeState : byte
+{
+ ///
+ /// Idle state before guild registration opens.
+ ///
+ Idle1 = 0,
+
+ ///
+ /// Guilds may register for the siege.
+ ///
+ RegisterGuild = 1,
+
+ ///
+ /// Idle state after guild registration.
+ ///
+ Idle2 = 2,
+
+ ///
+ /// Guilds may register emblems (Marks of Lord) to determine the attacking guilds.
+ ///
+ RegisterMark = 3,
+
+ ///
+ /// Idle state after mark registration.
+ ///
+ Idle3 = 4,
+
+ ///
+ /// Players are notified that the siege is about to start.
+ ///
+ Notify = 5,
+
+ ///
+ /// The siege map is prepared and entry is allowed.
+ ///
+ Ready = 6,
+
+ ///
+ /// The siege battle is in progress.
+ ///
+ Start = 7,
+
+ ///
+ /// The siege battle has ended and results are being processed.
+ ///
+ End = 8,
+
+ ///
+ /// The full siege cycle has completed.
+ ///
+ EndCycle = 9,
+}
diff --git a/src/DataModel/Configuration/CastleSiegeStateScheduleEntry.cs b/src/DataModel/Configuration/CastleSiegeStateScheduleEntry.cs
new file mode 100644
index 0000000..3065f40
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeStateScheduleEntry.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+
+///
+/// Defines a scheduled transition to a specific at a given day and time.
+///
+[Cloneable]
+public partial class CastleSiegeStateScheduleEntry
+{
+ ///
+ /// Gets or sets the siege state that becomes active at the scheduled time.
+ ///
+ public CastleSiegeState State { get; set; }
+
+ ///
+ /// Gets or sets the day of the week on which this state transition occurs.
+ ///
+ public DayOfWeek DayOfWeek { get; set; }
+
+ ///
+ /// Gets or sets the hour (0–23) at which this state transition occurs.
+ ///
+ public byte Hour { get; set; }
+
+ ///
+ /// Gets or sets the minute (0–59) at which this state transition occurs.
+ ///
+ public byte Minute { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.State} on {this.DayOfWeek} at {this.Hour:D2}:{this.Minute:D2}";
+ }
+}
diff --git a/src/DataModel/Configuration/CastleSiegeUpgradeDefinition.cs b/src/DataModel/Configuration/CastleSiegeUpgradeDefinition.cs
new file mode 100644
index 0000000..dd372c1
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeUpgradeDefinition.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+
+///
+/// Defines one level of an upgrade that the castle owner can apply to a gate or statue NPC.
+///
+[Cloneable]
+public partial class CastleSiegeUpgradeDefinition
+{
+ ///
+ /// Gets or sets the upgrade level (0–3), where 0 represents the base/unupgraded state.
+ ///
+ public byte Level { get; set; }
+
+ ///
+ /// Gets or sets the number of Jewels of Guardian required to perform this upgrade.
+ ///
+ public int RequiredJewelOfGuardianCount { get; set; }
+
+ ///
+ /// Gets or sets the amount of Zen required to perform this upgrade.
+ ///
+ public int RequiredZen { get; set; }
+
+ ///
+ /// Gets or sets the resulting stat value granted by this upgrade level (defense or max HP).
+ ///
+ public int Value { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"Level {this.Level}: Value={this.Value}, Jewels={this.RequiredJewelOfGuardianCount}, Zen={this.RequiredZen}";
+ }
+}
diff --git a/src/DataModel/Configuration/CastleSiegeUpgradeType.cs b/src/DataModel/Configuration/CastleSiegeUpgradeType.cs
new file mode 100644
index 0000000..25fa16e
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeUpgradeType.cs
@@ -0,0 +1,31 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+///
+/// The type of upgrade applied to a castle siege NPC (gate or statue).
+///
+public enum CastleSiegeUpgradeType : byte
+{
+ ///
+ /// No upgrade type assigned.
+ ///
+ Undefined = 0,
+
+ ///
+ /// Increases the defense stat of the NPC.
+ ///
+ Defense = 1,
+
+ ///
+ /// Increases the HP regeneration rate of the NPC.
+ ///
+ Regen = 2,
+
+ ///
+ /// Increases the maximum HP of the NPC.
+ ///
+ Life = 3,
+}
diff --git a/src/DataModel/Configuration/CastleSiegeZoneDefinition.cs b/src/DataModel/Configuration/CastleSiegeZoneDefinition.cs
new file mode 100644
index 0000000..602042b
--- /dev/null
+++ b/src/DataModel/Configuration/CastleSiegeZoneDefinition.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+
+///
+/// Defines a rectangular zone on the castle siege map, used for spawn areas and machine zones.
+///
+[Cloneable]
+public partial class CastleSiegeZoneDefinition
+{
+ ///
+ /// Gets or sets the top-left X coordinate of the zone.
+ ///
+ public byte X1 { get; set; }
+
+ ///
+ /// Gets or sets the top-left Y coordinate of the zone.
+ ///
+ public byte Y1 { get; set; }
+
+ ///
+ /// Gets or sets the bottom-right X coordinate of the zone.
+ ///
+ public byte X2 { get; set; }
+
+ ///
+ /// Gets or sets the bottom-right Y coordinate of the zone.
+ ///
+ public byte Y2 { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.X1} / {this.Y1} to {this.X2} / {this.Y2}";
+ }
+}
diff --git a/src/DataModel/Configuration/GameConfiguration.cs b/src/DataModel/Configuration/GameConfiguration.cs
index 564e181..9f6941a 100644
--- a/src/DataModel/Configuration/GameConfiguration.cs
+++ b/src/DataModel/Configuration/GameConfiguration.cs
@@ -300,6 +300,12 @@ public partial class GameConfiguration
[MemberOfAggregate]
public virtual ICollection MiniGameDefinitions { get; protected set; } = null!;
+ ///
+ /// Gets or sets the castle siege configuration.
+ ///
+ [MemberOfAggregate]
+ public virtual CastleSiegeConfiguration? CastleSiegeConfiguration { get; set; }
+
///
public override string ToString()
{
diff --git a/src/DataModel/Configuration/MonsterDefinition.cs b/src/DataModel/Configuration/MonsterDefinition.cs
index 59a1d7c..6263e55 100644
--- a/src/DataModel/Configuration/MonsterDefinition.cs
+++ b/src/DataModel/Configuration/MonsterDefinition.cs
@@ -165,6 +165,16 @@ public enum NpcWindow
/// The dialog for the legacy quest system.
///
LegacyQuest,
+
+ ///
+ /// The castle siege gate NPC interaction window.
+ ///
+ CastleSiegeGateNpc,
+
+ ///
+ /// The castle siege lever NPC interaction window.
+ ///
+ CastleSiegeLeverNpc,
}
///
diff --git a/src/DataModel/Entities/CastleSiegeData.cs b/src/DataModel/Entities/CastleSiegeData.cs
new file mode 100644
index 0000000..5067fcd
--- /dev/null
+++ b/src/DataModel/Entities/CastleSiegeData.cs
@@ -0,0 +1,67 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Entities;
+
+///
+/// Persistent state of the castle siege, stored as a single row across siege cycles.
+///
+[AggregateRoot]
+public class CastleSiegeData
+{
+ ///
+ /// Gets or sets the unique identifier of this record.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets or sets the persistent identifier of the guild that currently owns the castle.
+ /// when no guild owns the castle.
+ ///
+ public Guid? OwnerGuildId { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether any guild currently occupies the castle.
+ ///
+ public bool IsOccupied { get; set; }
+
+ ///
+ /// Gets or sets the Chaos Machine tax rate applied by the castle owner (0–3).
+ ///
+ public byte TaxChaos { get; set; }
+
+ ///
+ /// Gets or sets the personal store tax rate applied by the castle owner (0–3).
+ ///
+ public byte TaxStore { get; set; }
+
+ ///
+ /// Gets or sets the entry fee (in Zen) for the castle owner's hunt zone (0–300000).
+ ///
+ public int TaxHunt { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the hunt zone (Land of Trials) is currently open to the public.
+ ///
+ public bool IsHuntZoneEnabled { get; set; }
+
+ ///
+ /// Gets or sets the accumulated tribute money collected from the hunt zone and taxes.
+ ///
+ public long TributeMoney { get; set; }
+
+ ///
+ /// Gets or sets the persisted states of all castle NPCs.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection NpcStates { get; protected set; } = null!;
+
+ ///
+ public override string ToString()
+ {
+ return this.IsOccupied
+ ? $"Castle owned by guild {this.OwnerGuildId}"
+ : "Castle unoccupied";
+ }
+}
diff --git a/src/DataModel/Entities/CastleSiegeGuildRegistration.cs b/src/DataModel/Entities/CastleSiegeGuildRegistration.cs
new file mode 100644
index 0000000..39119ae
--- /dev/null
+++ b/src/DataModel/Entities/CastleSiegeGuildRegistration.cs
@@ -0,0 +1,44 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Entities;
+
+///
+/// Stores a guild's registration data for the current castle siege cycle,
+/// including the number of emblems submitted to determine attacking guilds.
+///
+[AggregateRoot]
+public class CastleSiegeGuildRegistration
+{
+ ///
+ /// Gets or sets the unique identifier of this registration record.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets or sets the persistent identifier of the registered guild.
+ ///
+ public Guid GuildId { get; set; }
+
+ ///
+ /// Gets or sets the guild name, denormalized for convenience to avoid extra lookups during siege processing.
+ ///
+ public string GuildName { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the number of Emblems of Lord registered by this guild.
+ ///
+ public int Marks { get; set; }
+
+ ///
+ /// Gets or sets the insertion order of this registration, used for tie-breaking when guilds have equal marks.
+ ///
+ public int RegistrationOrder { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.GuildName} (Marks={this.Marks}, Order={this.RegistrationOrder})";
+ }
+}
diff --git a/src/DataModel/Entities/CastleSiegeNpcState.cs b/src/DataModel/Entities/CastleSiegeNpcState.cs
new file mode 100644
index 0000000..509a0d8
--- /dev/null
+++ b/src/DataModel/Entities/CastleSiegeNpcState.cs
@@ -0,0 +1,52 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Entities;
+
+///
+/// Persistent state of a single castle siege NPC between siege cycles.
+///
+public class CastleSiegeNpcState
+{
+ ///
+ /// Gets or sets the unique identifier of this NPC state.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets or sets the monster definition number that identifies the NPC template.
+ ///
+ public short MonsterNumber { get; set; }
+
+ ///
+ /// Gets or sets the instance identifier matching .
+ ///
+ public byte InstanceId { get; set; }
+
+ ///
+ /// Gets or sets the current defense upgrade level (0–3).
+ ///
+ public byte DefenseLevel { get; set; }
+
+ ///
+ /// Gets or sets the current HP regeneration upgrade level (0–3).
+ ///
+ public byte RegenLevel { get; set; }
+
+ ///
+ /// Gets or sets the current maximum HP upgrade level (0–3).
+ ///
+ public byte LifeLevel { get; set; }
+
+ ///
+ /// Gets or sets the current HP of the NPC. A value of 0 means the NPC is destroyed.
+ ///
+ public int CurrentHp { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"NPC {this.MonsterNumber} #{this.InstanceId} (HP={this.CurrentHp})";
+ }
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
index 4a5ef2e..67eec74 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeContext.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -4,109 +4,151 @@
namespace MUnique.OpenMU.GameLogic.CastleSiege;
+using MUnique.OpenMU.DataModel.Configuration;
+
///
-/// 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.
-/// 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.
+///
+/// The cycle uses the original Season 6 values, which are exactly the values
+/// the game client expects (see CASTLESIEGE_STATE in the client's WSclient.h). AdaMu drives only
+/// a subset of them, because it registers guilds directly and has no Mark of Lord step:
+///
+///
+/// Idle1(0) -> RegisterGuild(1) -> Ready(6) -> Start(7) -> End(8) -> EndCycle(9) -> Idle1(0)
+///
+///
+/// The skipped states (, ,
+/// , ) keep their numbers so the
+/// client stays compatible; the server simply never enters them.
+///
+///
+/// Guilds are identified by their persistent , 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.
+///
+/// Battle rule: a guild takes the throne by holding BOTH Crown Switches at the same time. A switch is
+/// operated by clicking it and then staying in its area: the operation needs
+/// to complete, after which the switch counts as held
+/// until its operator leaves. While one guild holds both switches the crown's shield drops for it, and its
+/// guild master can start the crown hold to capture the throne. The throne can change hands as often as the
+/// switches do; whoever holds it when the siege ends becomes the castle owner.
///
public class CastleSiegeContext
{
/// The Crown Switch NPC numbers on Valley of Loren; both must be held to take the throne.
public static readonly short[] SwitchNumbers = { 217, 218 };
- private readonly List _registeredGuilds = new();
- private readonly Dictionary _switchHolders = new() { { 217, null }, { 218, null } };
- private DateTime _phaseStartedUtc;
- private string? _occupier;
+ private readonly Dictionary _registeredGuilds = new();
+ private readonly Dictionary _switches = 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 Guid? _crownHoldRequestedBy;
private bool _lastShieldDown;
/// Initializes a new instance of the class.
/// The cycle timing configuration.
- public CastleSiegeContext(CastleSiegeConfiguration configuration)
+ public CastleSiegeContext(CastleSiegeSettings configuration)
{
this.Configuration = configuration;
- this.Phase = CastleSiegePhase.Ownership;
+ this.State = CastleSiegeState.Idle1;
}
- /// Raised after the phase changes. Argument is the new phase.
- public event Action? PhaseChanged;
+ /// Raised after the state changes. Argument is the new state.
+ public event Action? StateChanged;
/// Gets the configuration (durations + schedule). Refreshed each tick from the live plugin config
/// so AdminPanel edits take effect without a restart.
- public CastleSiegeConfiguration Configuration { get; private set; }
+ public CastleSiegeSettings Configuration { get; private set; }
- /// Points the context at the current (possibly AdminPanel-edited) plugin configuration.
- /// The live configuration.
- public void UpdateConfiguration(CastleSiegeConfiguration configuration) => this.Configuration = configuration;
+ /// Gets the current state.
+ public CastleSiegeState State { get; private set; }
- /// Gets the current phase.
- public CastleSiegePhase Phase { get; private set; }
+ /// Gets the UTC time the current state started (used for persistence/restore).
+ public DateTime StateStartedUtc => this._stateStartedUtc;
- /// Gets the UTC time the current phase started (used for persistence/restore).
- public DateTime PhaseStartedUtc => this._phaseStartedUtc;
+ /// Gets the persistent identifier of the owner guild, or if unowned.
+ public Guid? OwnerGuildId { get; private set; }
- /// Gets the current owner guild name, or null if unowned.
+ /// Gets the owner guild's name for display and client packets, or null.
public string? OwnerGuildName { get; private set; }
- /// Gets the guild currently holding the throne during the siege (P3), or null.
- public string? OccupierGuildName => this._occupier;
+ /// Gets the guild currently holding the throne during the siege, or null.
+ public Guid? OccupierGuildId => this._occupier;
+
+ /// Gets the throne holder's name for display and client packets, or null.
+ public string? OccupierGuildName => this._occupierName;
/// Gets the number of castle defenses (gates + statues) still standing; the throne needs 0.
public int DefensesRemaining => this._defensesRemaining;
- /// Gets the guild names registered for the current cycle.
- public IReadOnlyList RegisteredGuilds => this._registeredGuilds;
+ /// Gets the persistent identifiers of the guilds registered for the current cycle.
+ public IReadOnlyCollection RegisteredGuildIds => this._registeredGuilds.Keys;
+
+ /// Gets the names of the guilds registered for the current cycle (display only).
+ public IReadOnlyCollection RegisteredGuildNames => this._registeredGuilds.Values;
+
+ /// Gets a value indicating whether the siege battle is currently running.
+ public bool IsSiegeRunning => this.State == CastleSiegeState.Start;
+
+ /// Points the context at the current (possibly AdminPanel-edited) plugin configuration.
+ /// The live configuration.
+ public void UpdateConfiguration(CastleSiegeSettings configuration) => this.Configuration = configuration;
+
+ /// Returns whether the given guild is registered for the current cycle.
+ /// The persistent guild identifier.
+ public bool IsRegistered(Guid guildId) => this._registeredGuilds.ContainsKey(guildId);
/// Advances the state machine based on the current time.
/// The current UTC time.
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 +156,67 @@ public class CastleSiegeContext
return ValueTask.CompletedTask;
}
- /// Admin: forces the cycle into registration now (from any phase).
+ /// Admin: forces the cycle into guild registration now (from any state).
/// The current UTC time.
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
- return this.TransitionAsync(CastleSiegePhase.Registration, now);
+ return this.TransitionAsync(CastleSiegeState.RegisterGuild, now);
}
- /// Admin: forces a specific phase now.
- /// The target phase.
+ /// Admin: forces a specific state now.
+ /// The target state.
/// The current UTC time.
- public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
- => this.TransitionAsync(phase, now);
+ public ValueTask ForceStateAsync(CastleSiegeState state, DateTime now)
+ => this.TransitionAsync(state, now);
- /// Admin: resets to the ownership (resting) phase and clears registrations/battle state.
+ /// Admin: resets to the idle (resting) state and clears registrations/battle state.
/// The current UTC time.
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
this.ClearBattleState();
- return this.TransitionAsync(CastleSiegePhase.Ownership, now);
+ return this.TransitionAsync(CastleSiegeState.Idle1, now);
}
- /// Registers a guild (by name) for the current cycle. No-op outside registration.
- /// The guild name.
- public void RegisterGuild(string guildName)
+ /// Registers a guild for the current cycle. No-op outside the registration state.
+ /// The persistent guild identifier.
+ /// The guild name, for display.
+ 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;
}
}
- /// Admin: sets (or clears) the current owner guild name.
- /// The owner guild name, or null to clear.
- public void SetOwner(string? guildName)
+ /// Admin: sets (or clears) the current owner guild.
+ /// The owner guild identifier, or null to clear.
+ /// The owner guild name, or null.
+ public void SetOwner(Guid? guildId, string? guildName)
{
- this.OwnerGuildName = guildName;
+ this.OwnerGuildId = guildId;
+ this.OwnerGuildName = guildId is null ? null : guildName;
this._dirty = true;
}
///
- /// 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).
///
- public void SyncOwnerFromConfig()
+ /// The owner guild identifier, or null.
+ /// The owner guild name, or null.
+ public void SyncOwner(Guid? guildId, string? guildName)
{
- this.OwnerGuildName = this.Configuration.PersistedOwnerGuildName;
+ this.OwnerGuildId = guildId;
+ this.OwnerGuildName = guildId is null ? null : guildName;
}
///
- /// 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.
///
@@ -192,83 +239,127 @@ public class CastleSiegeContext
/// The current UTC time.
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;
}
///
- /// Returns the guild that currently holds BOTH crown switches while all castle defenses are down (so the
- /// crown's shield is dropped for them), or null. Only meaningful during the siege.
+ /// Returns how much time is left in the current state, or when the state has
+ /// no duration (idle states wait for an admin command or the auto-open time).
///
- public string? GetShieldEligibleGuild()
+ /// The current UTC time.
+ public TimeSpan GetRemainingStateTime(DateTime nowUtc)
{
- if (this.Phase != CastleSiegePhase.Siege || this._defensesRemaining > 0)
+ 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;
+ }
+
+ ///
+ /// Returns the guild which currently holds BOTH Crown Switches, so the crown's shield is dropped for it,
+ /// or null. Only meaningful during the siege.
+ ///
+ public Guid? GetShieldEligibleGuild()
+ {
+ if (!this.IsSiegeRunning)
{
return null;
}
- var holder = this._switchHolders[217];
- return holder is not null && holder == this._switchHolders[218] ? holder : null;
+ var first = this.GetHeldSwitchGuild(217);
+ return first is not null && first == this.GetHeldSwitchGuild(218) ? first : null;
}
///
- /// Advances the crown-hold capture. is the guild with both switches held
- /// and no defenses left (shield down); is whether that guild's master is
- /// standing on the crown. Captures the throne for the guild once it has held for .
- /// Losing a switch or the master leaving the crown resets the hold (contestable until the siege ends).
+ /// Registers a guild master's intent to take the crown, which is what the crown hold waits for: standing
+ /// on the crown alone does nothing until its guild master clicked it. Ignored when the guild does not
+ /// hold both switches, so a click can never arm a hold the guild isn't entitled to.
///
- /// The guild with both switches and no defenses, or null.
+ /// The requesting guild master's guild identifier.
+ /// if the request was accepted.
+ public bool RequestCrownHold(Guid guildId)
+ {
+ if (this.GetShieldEligibleGuild() != guildId || this._occupier == guildId)
+ {
+ return false;
+ }
+
+ this._crownHoldRequestedBy = guildId;
+ return true;
+ }
+
+ ///
+ /// Advances the crown-hold capture. is the guild holding both switches
+ /// (shield down) and is whether that guild's master stands on the crown.
+ /// The hold only runs after the master requested it via ; it captures the
+ /// throne once it ran for . Losing a switch or the master leaving the crown
+ /// resets the hold, and the crown has to be clicked again (contestable until the siege ends).
+ ///
+ /// The guild holding both switches, or null.
+ /// That guild's name, for display.
/// Whether that guild's master is on the crown.
/// The current UTC time.
/// How long the master must hold to capture.
- 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);
+ this.ResetCrownHold();
+ return new CrownTickResult(false, shieldChanged, CrownEvent.None, null, null);
}
var wasHolding = this._crownHoldGuild is not null;
- // The guild that already occupies the throne just holds it — no re-registration (avoids a capture loop).
+ // The guild that already occupies the throne just holds it - no re-registration (avoids a capture loop).
// Only a DIFFERENT guild taking both switches can register/capture (contest).
- var canCapture = eligibleGuild is not null && eligibleGuild != this._occupier;
+ var canCapture = eligibleGuild is not null
+ && eligibleGuild != this._occupier
+ && this._crownHoldRequestedBy == eligibleGuild;
if (!canCapture || !masterHolding)
{
- this._crownHoldGuild = null;
- this._crownHoldStartUtc = null;
- return new CrownTickResult(shieldDown, shieldChanged, wasHolding ? CrownEvent.HoldReset : CrownEvent.None, null);
+ this.ResetCrownHold();
+ 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._crownHoldGuild = null;
- this._crownHoldStartUtc = null;
+ this._occupierName = eligibleGuildName;
+ this.ResetCrownHold();
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);
}
///
@@ -283,22 +374,27 @@ public class CastleSiegeContext
}
///
- /// Restores persisted state on startup (owner, phase, phase-start, registrations) directly, without
- /// firing or marking the state dirty. Battle state stays cleared.
+ /// Restores persisted state on startup (owner, state, state-start, registrations) directly, without
+ /// firing or marking the state dirty. Battle state stays cleared.
///
- /// The persisted owner guild name, or null.
- /// The persisted phase.
- /// When the persisted phase started (UTC), or null to keep the default.
- /// The persisted registered guild names, or null.
- public void RestoreState(string? owner, CastleSiegePhase phase, DateTime? phaseStartedUtc, IEnumerable? registeredGuilds)
+ /// The persisted owner guild identifier, or null.
+ /// The persisted owner guild name, or null.
+ /// The persisted state.
+ /// When the persisted state started (UTC), or null to keep the default.
+ /// The persisted registrations (id to name), or null.
+ public void RestoreState(Guid? ownerGuildId, string? ownerGuildName, CastleSiegeState state, DateTime? stateStartedUtc, IEnumerable>? 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;
@@ -317,79 +413,124 @@ public class CastleSiegeContext
}
}
+ /// Returns who is currently operating a Crown Switch, or .
+ /// The Crown Switch NPC number (217 or 218).
+ public CastleSiegeSwitchOperation? GetSwitchOperation(short switchNumber)
+ => this._switches.TryGetValue(switchNumber, out var operation) ? operation : null;
+
///
- /// Sets which guild is currently standing on (holding) a Crown Switch. Called every tick by the plugin
- /// based on player positions. Pass null when no registered member stands on it. No-op outside the siege.
+ /// Starts operating a Crown Switch for a player who clicked it. A switch can only be operated by one
+ /// player at a time: while somebody else is on it, the click is refused and the caller is told who holds
+ /// it, which is what the client shows as "another siege team is running the crown switch".
///
/// The Crown Switch NPC number (217 or 218).
- /// The holding guild's name, or null.
- public void SetSwitchHolder(short switchNumber, string? guildName)
+ /// The clicking player's guild identifier.
+ /// The clicking player's guild name, for display.
+ /// The clicking player's object identifier on the map.
+ /// The clicking player's name, for display.
+ /// The switch NPC's object identifier on the map.
+ /// The current UTC time.
+ /// The outcome, and the current operation when the switch is taken.
+ public (CastleSiegeSwitchPush Result, CastleSiegeSwitchOperation? Operation) TryStartSwitchOperation(
+ short switchNumber,
+ Guid guildId,
+ string guildName,
+ ushort playerId,
+ string playerName,
+ ushort switchObjectId,
+ DateTime now)
{
- if (this.Phase == CastleSiegePhase.Siege && this._switchHolders.ContainsKey(switchNumber))
+ if (!this.IsSiegeRunning || !this._switches.ContainsKey(switchNumber))
{
- this._switchHolders[switchNumber] = guildName;
+ return (CastleSiegeSwitchPush.SiegeNotRunning, null);
}
+
+ if (this._switches[switchNumber] is { } current)
+ {
+ return current.PlayerId == playerId
+ ? (CastleSiegeSwitchPush.AlreadyYours, current)
+ : (CastleSiegeSwitchPush.TakenByOther, current);
+ }
+
+ var operation = new CastleSiegeSwitchOperation(guildId, guildName, playerId, playerName, switchObjectId, now);
+ this._switches[switchNumber] = operation;
+ return (CastleSiegeSwitchPush.Started, operation);
}
///
- /// Attempts to capture the throne for a guild (called when a member registers at the Sinior/Crown NPC).
- /// Succeeds only during the siege when the throne is free, all castle defenses are destroyed, and the
- /// guild is currently holding BOTH Crown Switches (a member standing on each).
+ /// Advances one Crown Switch. The operation is dropped as soon as its player is gone from the switch's
+ /// area, and completes - which makes the switch count for the guild - once it ran .
///
- /// The capturing guild's name.
- /// Whether it succeeded and a human-readable reason/result message.
- public (bool Success, string Reason) TryCaptureThrone(string guildName)
+ /// The Crown Switch NPC number (217 or 218).
+ /// Whether the operating player is still in the switch's area.
+ /// The current UTC time.
+ /// How long operating the switch takes.
+ /// What happened to the switch in this tick, and the operation it happened to.
+ public (CastleSiegeSwitchEvent Event, CastleSiegeSwitchOperation? Operation) TickSwitch(
+ short switchNumber,
+ bool operatorPresent,
+ DateTime now,
+ TimeSpan pushDuration)
{
- if (this.Phase != CastleSiegePhase.Siege)
+ if (!this._switches.TryGetValue(switchNumber, out var operation) || operation is null)
{
- return (false, "The siege is not running.");
+ return (CastleSiegeSwitchEvent.None, null);
}
- if (this._occupier is not null)
+ if (!this.IsSiegeRunning || !operatorPresent)
{
- return (false, this._occupier == guildName
- ? "Your guild already holds the throne."
- : $"The throne is already held by '{this._occupier}'.");
+ this._switches[switchNumber] = null;
+ return (CastleSiegeSwitchEvent.Released, operation);
}
- if (this._defensesRemaining > 0)
+ if (!operation.IsHeld && now - operation.StartedUtc >= pushDuration)
{
- return (false, $"Destroy the castle defenses first ({this._defensesRemaining} remaining).");
+ operation.MarkHeld();
+ return (CastleSiegeSwitchEvent.Held, operation);
}
- if (this._switchHolders[217] != guildName || this._switchHolders[218] != guildName)
- {
- return (false, "Your guild must be holding BOTH Crown Switches at once (stand a member on each).");
- }
-
- this._occupier = guildName;
- return (true, "throne captured");
+ return (CastleSiegeSwitchEvent.None, operation);
}
/// Returns a human-readable status summary for admin display.
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 Guid? GetHeldSwitchGuild(short switchNumber)
+ => this._switches[switchNumber] is { IsHeld: true } operation ? operation.GuildId : null;
+
+ private string DescribeSwitch(short switchNumber)
+ => this._switches[switchNumber] is { } operation
+ ? $"{operation.GuildName}/{operation.PlayerName}{(operation.IsHeld ? string.Empty : " (pushing)")}"
+ : "-";
+
+ private void ResetCrownHold()
+ {
+ this._crownHoldGuild = null;
+ this._crownHoldStartUtc = null;
+ this._crownHoldRequestedBy = null;
+ }
private void ClearBattleState()
{
- this._switchHolders[217] = null;
- this._switchHolders[218] = null;
+ this._switches[217] = null;
+ this._switches[218] = null;
this._defensesRemaining = 0;
this._occupier = null;
- this._crownHoldGuild = null;
- this._crownHoldStartUtc = null;
+ this._occupierName = null;
+ this.ResetCrownHold();
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 +555,6 @@ public enum CrownEvent
/// Whether the crown shield is currently down (both switches held, defenses cleared).
/// Whether the shield state changed this tick (only then should the client be told).
/// The event that occurred this tick.
-/// The guild the event refers to, if any.
-public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, string? Guild);
+/// The guild the event refers to, if any.
+/// That guild's name, for display and client packets.
+public readonly record struct CrownTickResult(bool ShieldDown, bool ShieldChanged, CrownEvent Event, Guid? GuildId, string? GuildName);
diff --git a/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs
index a9e5a48..c212ac7 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeGuardsmanTalkPlugIn.cs
@@ -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);
diff --git a/src/GameLogic/CastleSiege/CastleSiegePhase.cs b/src/GameLogic/CastleSiege/CastleSiegePhase.cs
deleted file mode 100644
index 0317868..0000000
--- a/src/GameLogic/CastleSiege/CastleSiegePhase.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-//
-// Licensed under the MIT License. See LICENSE file in the project root for full license information.
-//
-
-namespace MUnique.OpenMU.GameLogic.CastleSiege;
-
-///
-/// The phases of a Castle Siege cycle.
-///
-public enum CastleSiegePhase
-{
- /// Resting phase: castle is (un)owned, waiting for the next registration window.
- Ownership,
-
- /// Guilds can register to attack.
- Registration,
-
- /// Registration closed; defenders prepare before the siege starts.
- Preparation,
-
- /// The siege battle is running.
- Siege,
-
- /// Siege ended; determining the new owner.
- Settlement,
-}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs
similarity index 70%
rename from src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
rename to src/GameLogic/CastleSiege/CastleSiegeSettings.cs
index dfe54a0..5e98ca9 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeSettings.cs
@@ -1,4 +1,4 @@
-//
+//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
@@ -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;
///
-/// 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 + .
-/// 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.
+///
+/// This is deliberately separate from , 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.
+///
+///
+/// What lives where:
+///
+/// - Castle owner and guild registrations: database (CastleSiegeData, CastleSiegeGuildRegistration).
+/// - NPC/zone/upgrade definitions and crown hold time: database (GameConfiguration.CastleSiegeConfiguration).
+/// - Cycle durations, registration fee, designated server and the current state: here.
+///
+///
+/// A cycle runs Idle1 -> RegisterGuild -> Ready -> Start -> End -> EndCycle -> Idle1, and auto-starts when the
+/// current day/time matches + .
+/// 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.
///
-public class CastleSiegeConfiguration
+public class CastleSiegeSettings
{
///
/// 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
///
/// 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).
///
public IList RegistrationOpenTimes { get; set; } = new List();
@@ -70,17 +86,6 @@ public class CastleSiegeConfiguration
set => this.SiegeDuration = TimeSpan.FromMinutes(Math.Max(1, value));
}
- ///
- /// 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.
- ///
- [JsonIgnore]
- public int CrownHoldSeconds
- {
- get => (int)this.CrownHoldDuration.TotalSeconds;
- set => this.CrownHoldDuration = TimeSpan.FromSeconds(Math.Max(1, value));
- }
-
///
/// 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,23 @@ public class CastleSiegeConfiguration
[Browsable(false)]
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
- /// Gets or sets how long the guild master must hold the Crown to capture the throne.
- [Browsable(false)]
- public TimeSpan CrownHoldDuration { get; set; } = TimeSpan.FromSeconds(60);
+ ///
+ /// Gets or sets how many seconds a player has to operate a Crown Switch before it counts for their guild.
+ /// The player has to stay in the switch's area for that long, and keeps it until they leave.
+ ///
+ public int SwitchPushSeconds { get; set; } = 15;
- // --- 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.
+ // --- 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.
- /// Gets or sets the persisted castle owner guild name (null = unowned).
+ /// Gets or sets the persisted current state, so the cycle resumes after a restart.
[Browsable(false)]
- public string? PersistedOwnerGuildName { get; set; }
+ public CastleSiegeState PersistedState { get; set; } = CastleSiegeState.Idle1;
- /// Gets or sets the persisted current phase, so the cycle resumes after a restart.
+ /// Gets or sets when the persisted state started (UTC), or null if never persisted.
[Browsable(false)]
- public CastleSiegePhase PersistedPhase { get; set; } = CastleSiegePhase.Ownership;
-
- /// Gets or sets when the persisted phase started (UTC), or null if never persisted.
- [Browsable(false)]
- public DateTime? PersistedPhaseStartedUtc { get; set; }
-
- /// Gets or sets the persisted registered guild names for the current cycle.
- [Browsable(false)]
- public IList PersistedRegisteredGuilds { get; set; } = new List();
+ public DateTime? PersistedStateStartedUtc { get; set; }
///
/// Returns true if (UTC) matches a scheduled registration-open day and falls
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
new file mode 100644
index 0000000..d0e14fa
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchEvent.cs
@@ -0,0 +1,20 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// What happened to a Crown Switch during one tick.
+///
+public enum CastleSiegeSwitchEvent
+{
+ /// Nothing worth reporting.
+ None,
+
+ /// The operation completed, so the switch now counts for the operator's guild.
+ Held,
+
+ /// The operator left (or the siege ended), so the switch is free again.
+ Released,
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
new file mode 100644
index 0000000..4c41fa0
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchOperation.cs
@@ -0,0 +1,55 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// One player operating one Crown Switch. The player starts it by clicking the switch and keeps it by
+/// staying in its area; the switch counts as held for the guild once the operation has run its time.
+///
+public class CastleSiegeSwitchOperation
+{
+ /// Initializes a new instance of the class.
+ /// The operating player's guild identifier.
+ /// The operating player's guild name, for display.
+ /// The operating player's object identifier on the map.
+ /// The operating player's name, for display.
+ /// The switch NPC's object identifier on the map.
+ /// When the operation started (UTC).
+ public CastleSiegeSwitchOperation(Guid guildId, string guildName, ushort playerId, string playerName, ushort switchObjectId, DateTime startedUtc)
+ {
+ this.GuildId = guildId;
+ this.GuildName = guildName;
+ this.PlayerId = playerId;
+ this.PlayerName = playerName;
+ this.SwitchObjectId = switchObjectId;
+ this.StartedUtc = startedUtc;
+ }
+
+ /// Gets the operating player's guild identifier.
+ public Guid GuildId { get; }
+
+ /// Gets the operating player's guild name.
+ public string GuildName { get; }
+
+ /// Gets the operating player's object identifier on the map.
+ public ushort PlayerId { get; }
+
+ /// Gets the operating player's name.
+ public string PlayerName { get; }
+
+ /// Gets the switch NPC's object identifier on the map, which the client's packets refer to.
+ public ushort SwitchObjectId { get; }
+
+ /// Gets the point in time (UTC) when the operation started.
+ public DateTime StartedUtc { get; }
+
+ ///
+ /// Gets a value indicating whether the operation ran its time, so the switch counts for the guild.
+ ///
+ public bool IsHeld { get; private set; }
+
+ /// Marks the operation as completed, which makes the switch count for the guild.
+ internal void MarkHeld() => this.IsHeld = true;
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
new file mode 100644
index 0000000..65353a2
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchPush.cs
@@ -0,0 +1,23 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// The outcome of a player clicking a Crown Switch.
+///
+public enum CastleSiegeSwitchPush
+{
+ /// The player started operating the switch.
+ Started,
+
+ /// The player is already operating this switch.
+ AlreadyYours,
+
+ /// Somebody else is operating this switch.
+ TakenByOther,
+
+ /// The siege is not running, so the switches do nothing.
+ SiegeNotRunning,
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
new file mode 100644
index 0000000..25f772d
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeSwitchTalkPlugIn.cs
@@ -0,0 +1,92 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+using System.Runtime.InteropServices;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.GameLogic.Views.CastleSiege;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// Handles clicking a Crown Switch (NPC 217 / 218) on Valley of Loren. The click starts operating the
+/// switch, which the client shows as a progress box; the switch counts for the guild once the operation
+/// ran its time and stays theirs until the operating player leaves the switch's area. Only one player can
+/// operate a switch at a time - anybody else clicking it is told that another team is on it.
+///
+[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000217")]
+[PlugIn]
+[Display(Name = "Castle Siege Crown Switch", Description = "Operates a Crown Switch (NPC 217/218) during the Castle Siege.")]
+public class CastleSiegeSwitchTalkPlugIn : IPlayerTalkToNpcPlugIn
+{
+ ///
+ public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
+ {
+ if (!CastleSiegeContext.SwitchNumbers.Contains(npc.Definition.Number))
+ {
+ return;
+ }
+
+ // We drive the switch ourselves, so suppress the default "not implemented" message.
+ eventArgs.HasBeenHandled = true;
+
+ var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
+ if (context is null)
+ {
+ await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
+ return;
+ }
+
+ if (!context.IsSiegeRunning)
+ {
+ await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
+ return;
+ }
+
+ if (await CastleSiegeEventPlugIn.GetPersistentGuildAsync(player).ConfigureAwait(false) is not { } guild
+ || !context.IsRegistered(guild.Id))
+ {
+ await ShowAsync(player, "Only members of a registered guild can operate the Crown Switches.").ConfigureAwait(false);
+ return;
+ }
+
+ var (result, operation) = context.TryStartSwitchOperation(
+ npc.Definition.Number,
+ guild.Id,
+ guild.Name,
+ player.Id,
+ player.Name,
+ npc.Id,
+ DateTime.UtcNow);
+
+ switch (result)
+ {
+ case CastleSiegeSwitchPush.Started:
+ // The info packet goes first: it is what makes every client allocate its switch table, which
+ // the "switch released" packet later reads without checking that it exists.
+ await CastleSiegeEventPlugIn.BroadcastSwitchInfoAsync(player.GameContext, npc.Id, operation).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.SetCrownSwitchStateAsync(npc.Id, player.Id, 1)).ConfigureAwait(false);
+ break;
+
+ case CastleSiegeSwitchPush.TakenByOther when operation is { } other:
+ // State 2 makes the client name the player who is already on it.
+ await player.InvokeViewPlugInAsync(p => p.SetCrownSwitchStateAsync(npc.Id, other.PlayerId, 2)).ConfigureAwait(false);
+ break;
+
+ case CastleSiegeSwitchPush.AlreadyYours:
+ break;
+
+ default:
+ await ShowAsync(player, "The Crown Switches only work while the siege is running.").ConfigureAwait(false);
+ break;
+ }
+ }
+
+ private static ValueTask ShowAsync(Player player, string text)
+ => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
index 891af70..5ea1a42 100644
--- a/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
+++ b/src/GameLogic/CastleSiege/CastleSiegeThroneCaptureTalkPlugIn.cs
@@ -43,56 +43,58 @@ 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);
- }
-
- private static string DescribeThroneStep(CastleSiegeContext context, string guildName)
- {
- if (context.Phase != CastleSiegePhase.Siege)
+ // Clicking the Crown as the guild master is what arms the capture: the hold then runs while they
+ // stay on it. Anybody else (or a master who isn't entitled yet) just gets told what is missing.
+ var isGuildMaster = player.GuildStatus?.Position == GuildPosition.GuildMaster;
+ if (isGuildMaster && context.RequestCrownHold(guild.Id))
{
- return "The siege is not running yet.";
+ await ShowAsync(player, "Hold the Crown - do not step away until the seal is registered!").ConfigureAwait(false);
+ return;
}
- if (context.DefensesRemaining > 0)
+ await ShowAsync(player, DescribeThroneStep(context, guild.Id, isGuildMaster)).ConfigureAwait(false);
+ }
+
+ private static string DescribeThroneStep(CastleSiegeContext context, Guid guildId, bool isGuildMaster)
+ {
+ if (!context.IsSiegeRunning)
{
- return $"Destroy all castle gates first ({context.DefensesRemaining} remaining), then hold both Crown Switches.";
+ return "The siege is not running yet.";
}
var eligible = context.GetShieldEligibleGuild();
if (eligible is null)
{
- return "All gates are down! Hold BOTH Crown Switches with your guild — the Crown's shield will drop.";
+ return context.DefensesRemaining > 0
+ ? $"Hold BOTH Crown Switches with your guild to drop the Crown's shield ({context.DefensesRemaining} castle defenses still standing)."
+ : "Hold BOTH Crown Switches with your guild - the Crown's shield will drop.";
}
- if (eligible == 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 "Another guild is holding both switches. Take a switch back to raise their shield.";
}
- return $"Guild '{eligible}' is holding both switches. Take a switch back to raise their shield.";
+ if (context.OccupierGuildId == guildId)
+ {
+ return "Your guild already holds the throne - keep it until the siege ends.";
+ }
+
+ return isGuildMaster
+ ? "Your guild holds both switches, but the Crown cannot be registered right now."
+ : "Your guild holds both switches and the shield is down - your GUILD MASTER has to click the Crown!";
}
private static ValueTask ShowAsync(Player player, string text)
diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs
index 64a7da6..5b9928b 100644
--- a/src/GameLogic/Player.cs
+++ b/src/GameLogic/Player.cs
@@ -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;
///
public async ValueTask AttackByAsync(IAttacker attacker, SkillEntry? skill, bool isCombo, double damageFactor = 1.0, bool? isFinalStreakHit = null)
diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs
index 3ea4095..b288ae6 100644
--- a/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs
@@ -14,7 +14,7 @@ using MUnique.OpenMU.PlugIns;
/// Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.
[Guid("A1B2C3D4-0003-4E5F-9A0B-CA5710000003")]
[PlugIn]
-[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase ")]
+[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase ")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{
@@ -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(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(parts[1], true, out var phase))
{
- await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Usage: /csphase ", MessageType.BlueNormal)).ConfigureAwait(false);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Usage: /csphase ", 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(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
diff --git a/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs
index 5f1e4e6..e389b93 100644
--- a/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs
+++ b/src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs
@@ -38,7 +38,22 @@ public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
return;
}
- context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner);
- await player.InvokeViewPlugInAsync(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(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(p => p.ShowMessageAsync($"No guild named '{owner}' was found.", MessageType.BlueNormal)).ConfigureAwait(false);
+ return;
+ }
+
+ context.SetOwner(id, owner);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Castle Siege owner set to {owner}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
diff --git a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
index 93c65d3..731a37c 100644
--- a/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
+++ b/src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs
@@ -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,69 @@ using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Pathfinding;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
+using CastleSiegeDefinition = MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration;
///
-/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration.
-/// State is per- 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.
+///
+/// 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 ( and
+/// ) and keyed by the guild's persistent , 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.
+///
+///
+/// Castle NPCs (gates, statues, catapults, the crown and its switches) are read from
+/// , which the CastleSiegeInitializer seeds, instead
+/// of being hard-coded here.
+///
+/// When the siege starts, registered guild members are warped to the Valley of Loren battle map.
///
[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, ISupportDefaultCustomConfiguration
+public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, 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 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 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 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 CrownHoldRange = 4;
+ /// How many ticks (the periodic task runs once per second) between two countdown broadcasts.
+ private const int SiegeStateBroadcastTicks = 10;
+
+ /// How many ticks between two castle-flag broadcasts.
+ private const int CastleFlagBroadcastTicks = 15;
+
private static readonly ConcurrentDictionary Contexts = new();
- private string? _cachedFlagOwner;
+ ///
+ /// The player whose client currently shows the crown registration panel, per game context. The panel is
+ /// opened for exactly one guild master, and it has to be closed for that same player - by the time the
+ /// hold breaks they are usually no longer on the crown, so they can't be found by position any more.
+ ///
+ private static readonly ConcurrentDictionary CrownHoldPlayers = new();
+
+ ///
+ /// Tick counters per game context, used to space out the periodic broadcasts. Counting ticks (instead of
+ /// matching a clock second) keeps a broadcast from being skipped when a tick runs late.
+ ///
+ private static readonly ConcurrentDictionary Counters = new();
+
+ ///
+ /// 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.
+ ///
+ private static readonly ConcurrentDictionary PersistentGuildIds = new();
+
+ private Guid? _cachedFlagOwner;
private byte[]? _cachedFlagLogo;
///
- public CastleSiegeConfiguration? Configuration { get; set; }
+ public CastleSiegeSettings? Configuration { get; set; }
///
/// Gets the Castle Siege context for a game context, if the periodic tick has initialized it.
@@ -99,41 +104,80 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
return serverContext.Id == context.Configuration.CastleSiegeServerId;
}
+ ///
+ /// Resolves the persistent identifier of the player's guild, or when the player is
+ /// not in a guild or the guild cannot be resolved.
+ ///
+ ///
+ /// deliberately carries no id: the guild server assigns short ids in memory
+ /// only. The persistent is therefore resolved through the guild name and cached, which
+ /// avoids adding a method to that upstream would keep changing.
+ ///
+ /// The player.
+ 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);
+ }
+
///
- public object CreateDefaultConfig() => new CastleSiegeConfiguration();
+ public object CreateDefaultConfig() => new CastleSiegeSettings();
///
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)
+ if (GetCounters(gameContext).NextCastleFlag())
{
+ await LoadPersistedStateAsync(gameContext, context).ConfigureAwait(false);
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
@@ -143,19 +187,19 @@ 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).
- if (DateTime.UtcNow.Second % 15 == 0)
+ // Keep the owner guild's logo painted on the castle flags for anyone on the castle map (any state).
+ if (GetCounters(gameContext).NextCastleFlag())
{
await this.BroadcastCastleFlagAsync(gameContext, context).ConfigureAwait(false);
}
@@ -171,25 +215,98 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
- private static async Task OnPhaseChangedAsync(IGameContext gameContext, CastleSiegeContext context, CastleSiegePhase phase)
+ /// Gets the seeded Castle Siege definition, or when it was not initialized.
+ /// The game context.
+ private static CastleSiegeDefinition? GetDefinition(IGameContext gameContext)
+ => gameContext.Configuration.CastleSiegeConfiguration;
+
+ /// Resolves a guild's persistent identifier from its name, or null when there is no such guild.
+ /// The game context.
+ /// The guild name.
+ internal static async ValueTask 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().ConfigureAwait(false);
+ return guilds.FirstOrDefault(guild => guild.Name == guildName)?.Id;
+ }
+ catch (Exception ex)
+ {
+ gameContext.LoggerFactory.CreateLogger()
+ .LogError(ex, "Castle Siege: could not resolve the persistent id of guild '{guildName}'.", guildName);
+ return null;
+ }
+ }
+
+ private static async ValueTask ResolveGuildNameByIdAsync(IGameContext gameContext, Guid guildId)
+ {
+ try
+ {
+ using var context = gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(DataModel.Entities.Guild), false, gameContext.Configuration);
+ var guild = await context.GetByIdAsync(guildId).ConfigureAwait(false);
+ return guild?.Name;
+ }
+ catch (Exception ex)
+ {
+ gameContext.LoggerFactory.CreateLogger()
+ .LogError(ex, "Castle Siege: could not resolve the name of guild {guildId}.", guildId);
+ return null;
+ }
+ }
+
+ /// Loads the persisted castle owner and guild registrations from the database into the context.
+ 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().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().ConfigureAwait(false);
+
+ var restored = new List>();
+ foreach (var registration in registrations)
{
- case CastleSiegePhase.Registration:
+ var name = await ResolveGuildNameByIdAsync(gameContext, registration.GuildId).ConfigureAwait(false);
+ restored.Add(new KeyValuePair(registration.GuildId, name ?? registration.GuildId.ToString()));
+ }
+
+ context.RestoreState(ownerId, ownerName, context.State, context.StateStartedUtc, restored);
+ }
+ catch (Exception ex)
+ {
+ gameContext.LoggerFactory.CreateLogger()
+ .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:
- // Stop the on-map countdown for everyone still on the battle map.
+ case CastleSiegeState.End:
+ // Stop the on-map countdown for everyone still on the battle map, and close the panels
+ // the siege opened - the battle state is dropped right after this, so whoever was
+ // operating a switch or holding the crown would keep a dead progress box on screen.
await BroadcastSiegeStateAsync(gameContext, false, 0, 0).ConfigureAwait(false);
+ await CloseSiegePanelsAsync(gameContext, context).ConfigureAwait(false);
break;
- case 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 +316,7 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger()
- .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(inMemory.GetId()).ConfigureAwait(false);
- if (row is null)
- {
- return;
- }
-
- row.SetConfiguration(config, gameContext.PlugInManager.CustomConfigReferenceHandler);
- await ctx.SaveChangesAsync().ConfigureAwait(false);
-
- gameContext.LoggerFactory.CreateLogger()
- .LogInformation("Castle Siege: persisted state (owner={owner}, phase={phase}).", config.PersistedOwnerGuildName ?? "(none)", config.PersistedPhase);
- }
- catch (Exception ex)
- {
- gameContext.LoggerFactory.CreateLogger()
- .LogError(ex, "Castle Siege: error while persisting state to the database.");
+ .LogError(ex, "Castle Siege: error handling state change to {state}.", state);
}
}
@@ -269,52 +341,36 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
+ ///
+ /// Spawns the castle defenses from the seeded NPC definitions. Definitions flagged
+ /// are the breakable defenses (gates and
+ /// guardian statues) and are counted towards the throne; the catapults are pure war atmosphere.
+ ///
private static async Task SpawnCastleDefensesAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
- if (gameContext is not GameContext concrete)
- {
- context.SetDefenseCount(0);
- return;
- }
-
- var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
- if (map is null)
+ 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 template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
var spawned = 0;
- for (var i = 0; i < DefenseSpawns.Length; i++)
+ var index = 0;
+ foreach (var npc in definition.NpcDefinitions.Where(n => n.IsPersistedToDatabase && n.MonsterDefinition is not null))
{
- 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
+ // 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).
- var template = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GateTemplateNumber);
- EnsureDestructible(definition, template);
+ EnsureDestructible(npc.MonsterDefinition!, template);
- 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,
- };
-
- 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 +379,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,54 +395,144 @@ 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 BroadcastCounters GetCounters(IGameContext gameContext)
+ => Counters.GetOrAdd(gameContext, _ => new BroadcastCounters());
+
+ ///
+ /// Closes the client panels the siege opened: the crown registration panel of the master who was holding
+ /// it, and the switch progress box of whoever was operating a switch. Called when the siege ends, before
+ /// the battle state is dropped.
+ ///
+ private static async Task CloseSiegePanelsAsync(IGameContext gameContext, CastleSiegeContext context)
+ {
+ if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
+ {
+ await holdPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
+ }
+
+ if (await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false) is not { } map)
+ {
+ return;
+ }
+
+ foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
+ {
+ if (context.GetSwitchOperation(switchNumber) is { } operation)
+ {
+ await BroadcastSwitchInfoAsync(gameContext, operation.SwitchObjectId, null).ConfigureAwait(false);
+ await CloseSwitchBoxAsync(map, operation).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Closes the switch progress box on the client of the player who was operating it. Object identifiers
+ /// are recycled when a player leaves, so the name is checked too - otherwise a newly connected player
+ /// could inherit the id and get a message box about a switch they never touched.
+ ///
+ private static async Task CloseSwitchBoxAsync(GameMap map, CastleSiegeSwitchOperation operation)
+ {
+ if (map.GetObject(operation.PlayerId) is Player player && player.Name == operation.PlayerName)
+ {
+ await player.InvokeViewPlugInAsync(
+ p => p.SetCrownSwitchStateAsync(operation.SwitchObjectId, operation.PlayerId, 0)).ConfigureAwait(false);
+ }
+ }
+
+ 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)
+ var now = DateTime.UtcNow;
+ var pushDuration = TimeSpan.FromSeconds(Math.Max(1, context.Configuration.SwitchPushSeconds));
+
+ // A Crown Switch belongs to the player who clicked it, for as long as they stay in its area.
+ foreach (var switchNumber in CastleSiegeContext.SwitchNumbers)
{
- string? holder = null;
- var nearby = map.GetAttackablesInRange(new Point(x, y), SwitchHoldRange).OfType();
- foreach (var player in nearby)
+ if (context.GetSwitchOperation(switchNumber) is not { } operation)
{
- var guildName = await GetGuildNameAsync(player).ConfigureAwait(false);
- if (guildName is not null && context.RegisteredGuilds.Contains(guildName))
- {
- holder = guildName;
- break;
- }
+ continue;
}
- context.SetSwitchHolder(switchNumber, holder);
+ var stillOnIt = GetNpcPosition(gameContext, switchNumber) is { } position
+ && map.GetAttackablesInRange(position, SwitchHoldRange)
+ .OfType()
+ .Any(p => p.Id == operation.PlayerId && p.IsAlive);
+
+ var (switchEvent, affected) = context.TickSwitch(switchNumber, stillOnIt, now, pushDuration);
+ if (affected is null)
+ {
+ continue;
+ }
+
+ if (switchEvent == CastleSiegeSwitchEvent.Held)
+ {
+ // Repeat the info so the HUD picks up the names (the client only stores them from the
+ // second packet on, because the first one allocates its table).
+ await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, affected).ConfigureAwait(false);
+ }
+ else if (switchEvent == CastleSiegeSwitchEvent.Released)
+ {
+ await BroadcastSwitchInfoAsync(gameContext, affected.SwitchObjectId, null).ConfigureAwait(false);
+
+ // Close the client's progress box of the player who left, if they are still around.
+ await CloseSwitchBoxAsync(map, affected).ConfigureAwait(false);
+ }
}
- var now = DateTime.UtcNow;
-
- // Crown-hold capture: when a guild holds both switches with every gate down, the crown shield drops;
- // that guild's master then holds the crown for CrownHoldDuration to take the throne (contestable).
+ // Crown-hold capture: while one guild holds both switches the crown shield drops for it, and its
+ // master captures the throne by clicking the crown and holding it for the configured time.
var eligible = context.GetShieldEligibleGuild();
Player? masterPlayer = null;
- 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())
+ foreach (var player in map.GetAttackablesInRange(crownPosition, CrownHoldRange).OfType())
{
- 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 +543,25 @@ 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. Remember them:
+ // the hold usually breaks BECAUSE they walked off the crown, and their client still has
+ // the panel open, so the cancel has to reach the player we started it for.
+ CrownHoldPlayers[gameContext] = masterPlayer;
await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(0, 0)).ConfigureAwait(false);
break;
- case CrownEvent.HoldReset when masterPlayer is not null:
- await masterPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
+ case CrownEvent.HoldReset:
+ if (CrownHoldPlayers.TryRemove(gameContext, out var holdPlayer))
+ {
+ await holdPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(2, 0)).ConfigureAwait(false);
+ }
+
break;
- case CrownEvent.Captured when crown.Guild is { } captured:
+ case CrownEvent.Captured when crown.GuildName is { } captured:
+ if (CrownHoldPlayers.TryRemove(gameContext, out var capturingPlayer))
+ {
+ await capturingPlayer.InvokeViewPlugInAsync(p => p.SetCrownRegistAsync(1, 0)).ConfigureAwait(false);
+ }
+
await ForEachOnBattleMapAsync(gameContext, p => p.AnnounceSealCapturedAsync(captured)).ConfigureAwait(false);
await AnnounceAsync(gameContext, $"Guild '{captured}' has taken the Crown and now holds the throne!").ConfigureAwait(false);
break;
@@ -428,9 +569,10 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
break;
}
- // Keep the client's on-map countdown armed and in sync. Resend every 10s so players who just
- // loaded the battle map pick it up, without visibly resetting the second-counter too often.
- if ((int)(now - context.PhaseStartedUtc).TotalSeconds % 10 == 0)
+ // Keep the client's on-map countdown armed and in sync. Resend every 10 ticks so players who just
+ // loaded the battle map pick it up, without visibly resetting the second-counter too often. This
+ // counts ticks instead of matching a clock second, which a delayed tick would skip silently.
+ if (GetCounters(gameContext).NextSiegeState())
{
var remaining = context.GetRemainingSiegeTime(now);
var totalMinutes = (int)Math.Ceiling(remaining.TotalMinutes);
@@ -464,22 +606,155 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
});
/// Invokes the Castle Siege status view for every player currently on the battle map.
+ ///
+ /// Tells everybody on the battle map who is operating a Crown Switch. Besides driving the client's HUD
+ /// list, this is what makes the client allocate its switch table, so it has to be sent before any
+ /// "switch released" packet reaches that client.
+ ///
+ /// The game context.
+ /// The switch NPC's object identifier.
+ /// The operation, or when the switch became free.
+ internal static ValueTask BroadcastSwitchInfoAsync(IGameContext gameContext, ushort switchObjectId, CastleSiegeSwitchOperation? operation)
+ => ForEachOnBattleMapAsync(
+ gameContext,
+ p => p.SetCrownSwitchInfoAsync(
+ switchObjectId,
+ operation is null ? (byte)0 : (byte)1,
+ (byte)CastleSiegeJoinSide.Attack1,
+ operation?.GuildName ?? string.Empty,
+ operation?.PlayerName ?? string.Empty));
+
private static ValueTask ForEachOnBattleMapAsync(IGameContext gameContext, Func action)
=> gameContext.ForEachPlayerAsync(player =>
player.CurrentMap?.Definition.Number == ValleyOfLorenMapNumber
? player.InvokeViewPlugInAsync(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()
+ .LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
+ }
+ }
+
+ ///
+ /// 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.
+ ///
+ 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()
+ .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().ConfigureAwait(false)).FirstOrDefault()
+ ?? dataContext.CreateNew();
+
+ data.OwnerGuildId = context.OwnerGuildId;
+ data.IsOccupied = context.OwnerGuildId is not null;
+ await dataContext.SaveChangesAsync().ConfigureAwait(false);
+
+ gameContext.LoggerFactory.CreateLogger()
+ .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().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();
+ 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(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 +775,58 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
}
}
- private async ValueTask GetOwnerLogoAsync(IGameContext gameContext, string ownerName)
+ private async ValueTask 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)
+ ///
+ /// Counts the ticks between the periodic broadcasts of one game context.
+ ///
+ private sealed class BroadcastCounters
{
- try
+ private int _siegeState;
+ private int _castleFlag;
+
+ /// Advances the countdown-broadcast counter and tells whether it is due.
+ public bool NextSiegeState() => Due(ref this._siegeState, SiegeStateBroadcastTicks);
+
+ /// Advances the castle-flag-broadcast counter and tells whether it is due.
+ public bool NextCastleFlag() => Due(ref this._castleFlag, CastleFlagBroadcastTicks);
+
+ private static bool Due(ref int counter, int period)
{
- var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
- if (map?.SafeZoneSpawnGate is not { } gate)
+ if (++counter < period)
{
- return;
+ return false;
}
- 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);
+ counter = 0;
+ return true;
}
- catch (Exception ex)
- {
- gameContext.LoggerFactory.CreateLogger()
- .LogError(ex, "Castle Siege: error while warping registered members to the battle map.");
- }
- }
-
- private static async ValueTask 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();
}
}
diff --git a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
index f99a79b..7050afe 100644
--- a/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
+++ b/src/GameLogic/Views/CastleSiege/ICastleSiegeStatusViewPlugIn.cs
@@ -49,4 +49,29 @@ public interface ICastleSiegeStatusViewPlugIn : IViewPlugIn
///
/// The capturing guild's name (max 8 bytes).
ValueTask AnnounceSealCapturedAsync(string guildName);
+
+ ///
+ /// Sends the state of a Crown Switch (C1 B2 14): state 0 = released (the client closes its progress
+ /// box), 1 = this player is operating it (the client opens its hold progress box), 2 = somebody else
+ /// is already operating it.
+ ///
+ /// The Crown Switch NPC's object identifier.
+ /// The operating player's object identifier.
+ /// The switch state (0 released, 1 operated by this player, 2 operated by another).
+ ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state);
+
+ ///
+ /// Sends who is operating a Crown Switch (C1 B2 20), which the client lists on the siege HUD.
+ ///
+ /// This has to reach a client BEFORE any with state 0: the client
+ /// allocates its switch table when this arrives, and its "switch released" handler reads that table
+ /// without checking whether it exists.
+ ///
+ ///
+ /// The Crown Switch NPC's object identifier.
+ /// 0 when nobody operates it, 1 while it is operated.
+ /// The operating side (see the castle siege join sides).
+ /// The operating guild's name (max 8 bytes), empty when free.
+ /// The operating player's name (max 10 bytes), empty when free.
+ ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName);
}
diff --git a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
index 80b8e99..9cfc2d5 100644
--- a/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
+++ b/src/GameServer/RemoteView/CastleSiege/CastleSiegeStatusViewPlugIn.cs
@@ -26,6 +26,17 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
/// The player.
public CastleSiegeStatusViewPlugIn(RemotePlayer player) => this._player = player;
+ private static void WriteName(string name, Span target)
+ {
+ if (name.Length == 0)
+ {
+ return;
+ }
+
+ var bytes = System.Text.Encoding.UTF8.GetBytes(name);
+ bytes.AsSpan(0, Math.Min(target.Length - 1, bytes.Length)).CopyTo(target);
+ }
+
///
public async ValueTask SetBattleStateAsync(bool started)
{
@@ -153,6 +164,65 @@ public class CastleSiegeStatusViewPlugIn : ICastleSiegeStatusViewPlugIn
await connection.SendAsync(WritePacket).ConfigureAwait(false);
}
+ ///
+ public async ValueTask SetCrownSwitchStateAsync(ushort switchObjectId, ushort playerObjectId, byte state)
+ {
+ var connection = this._player.Connection;
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ // C1 09 B2 14
+ var span = connection.Output.GetSpan(9)[..9];
+ span.Clear();
+ span[0] = 0xC1;
+ span[1] = 0x09;
+ span[2] = 0xB2;
+ span[3] = 0x14;
+ span[4] = (byte)(switchObjectId >> 8);
+ span[5] = (byte)switchObjectId;
+ span[6] = (byte)(playerObjectId >> 8);
+ span[7] = (byte)playerObjectId;
+ span[8] = state;
+ return span.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask SetCrownSwitchInfoAsync(ushort switchObjectId, byte switchState, byte joinSide, string guildName, string playerName)
+ {
+ var connection = this._player.Connection;
+ if (connection is null)
+ {
+ return;
+ }
+
+ int WritePacket()
+ {
+ // C1 1B B2 20
+ var span = connection.Output.GetSpan(27)[..27];
+ span.Clear();
+ span[0] = 0xC1;
+ span[1] = 0x1B;
+ span[2] = 0xB2;
+ span[3] = 0x20;
+ span[4] = (byte)(switchObjectId >> 8);
+ span[5] = (byte)switchObjectId;
+ span[6] = switchState;
+ span[7] = joinSide;
+ WriteName(guildName, span.Slice(8, 8));
+ WriteName(playerName, span.Slice(16, 10));
+ return span.Length;
+ }
+
+ await connection.SendAsync(WritePacket).ConfigureAwait(false);
+ }
+
///
public async ValueTask AnnounceSealCapturedAsync(string guildName)
{
diff --git a/src/Persistence/BasicModel/CastleSiegeConfiguration.Generated.cs b/src/Persistence/BasicModel/CastleSiegeConfiguration.Generated.cs
new file mode 100644
index 0000000..54c37fd
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeConfiguration.Generated.cs
@@ -0,0 +1,342 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeConfiguration : MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration, IIdentifiable, IConvertibleTo
+{
+
+ ///
+ /// Gets or sets the identifier of this instance.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("stateSchedule")]
+ public ICollection RawStateSchedule { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection StateSchedule
+ {
+ get => base.StateSchedule ??= new CollectionAdapter(this.RawStateSchedule);
+ protected set
+ {
+ this.StateSchedule.Clear();
+ foreach (var item in value)
+ {
+ this.StateSchedule.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("npcDefinitions")]
+ public ICollection RawNpcDefinitions { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection NpcDefinitions
+ {
+ get => base.NpcDefinitions ??= new CollectionAdapter(this.RawNpcDefinitions);
+ protected set
+ {
+ this.NpcDefinitions.Clear();
+ foreach (var item in value)
+ {
+ this.NpcDefinitions.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("gateDefenseUpgrades")]
+ public ICollection RawGateDefenseUpgrades { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection GateDefenseUpgrades
+ {
+ get => base.GateDefenseUpgrades ??= new CollectionAdapter(this.RawGateDefenseUpgrades);
+ protected set
+ {
+ this.GateDefenseUpgrades.Clear();
+ foreach (var item in value)
+ {
+ this.GateDefenseUpgrades.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("gateLifeUpgrades")]
+ public ICollection RawGateLifeUpgrades { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection GateLifeUpgrades
+ {
+ get => base.GateLifeUpgrades ??= new CollectionAdapter(this.RawGateLifeUpgrades);
+ protected set
+ {
+ this.GateLifeUpgrades.Clear();
+ foreach (var item in value)
+ {
+ this.GateLifeUpgrades.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("statueDefenseUpgrades")]
+ public ICollection RawStatueDefenseUpgrades { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection StatueDefenseUpgrades
+ {
+ get => base.StatueDefenseUpgrades ??= new CollectionAdapter(this.RawStatueDefenseUpgrades);
+ protected set
+ {
+ this.StatueDefenseUpgrades.Clear();
+ foreach (var item in value)
+ {
+ this.StatueDefenseUpgrades.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("statueLifeUpgrades")]
+ public ICollection RawStatueLifeUpgrades { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection StatueLifeUpgrades
+ {
+ get => base.StatueLifeUpgrades ??= new CollectionAdapter(this.RawStatueLifeUpgrades);
+ protected set
+ {
+ this.StatueLifeUpgrades.Clear();
+ foreach (var item in value)
+ {
+ this.StatueLifeUpgrades.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("statueRegenUpgrades")]
+ public ICollection RawStatueRegenUpgrades { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection StatueRegenUpgrades
+ {
+ get => base.StatueRegenUpgrades ??= new CollectionAdapter(this.RawStatueRegenUpgrades);
+ protected set
+ {
+ this.StatueRegenUpgrades.Clear();
+ foreach (var item in value)
+ {
+ this.StatueRegenUpgrades.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("attackMachineZones")]
+ public ICollection RawAttackMachineZones { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection AttackMachineZones
+ {
+ get => base.AttackMachineZones ??= new CollectionAdapter(this.RawAttackMachineZones);
+ protected set
+ {
+ this.AttackMachineZones.Clear();
+ foreach (var item in value)
+ {
+ this.AttackMachineZones.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("defenseMachineZones")]
+ public ICollection RawDefenseMachineZones { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection DefenseMachineZones
+ {
+ get => base.DefenseMachineZones ??= new CollectionAdapter(this.RawDefenseMachineZones);
+ protected set
+ {
+ this.DefenseMachineZones.Clear();
+ foreach (var item in value)
+ {
+ this.DefenseMachineZones.Add(item);
+ }
+ }
+ }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("castleSiegeMapDefinition")]
+ public GameMapDefinition RawCastleSiegeMapDefinition
+ {
+ get => base.CastleSiegeMapDefinition as GameMapDefinition;
+ set => base.CastleSiegeMapDefinition = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition CastleSiegeMapDefinition
+ {
+ get => base.CastleSiegeMapDefinition;
+ set => base.CastleSiegeMapDefinition = value;
+ }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("landOfTrialsMapDefinition")]
+ public GameMapDefinition RawLandOfTrialsMapDefinition
+ {
+ get => base.LandOfTrialsMapDefinition as GameMapDefinition;
+ set => base.LandOfTrialsMapDefinition = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.GameMapDefinition LandOfTrialsMapDefinition
+ {
+ get => base.LandOfTrialsMapDefinition;
+ set => base.LandOfTrialsMapDefinition = value;
+ }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("rewardItemDefinition")]
+ public ItemDefinition RawRewardItemDefinition
+ {
+ get => base.RewardItemDefinition as ItemDefinition;
+ set => base.RewardItemDefinition = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.Items.ItemDefinition RewardItemDefinition
+ {
+ get => base.RewardItemDefinition;
+ set => base.RewardItemDefinition = value;
+ }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("defenseRespawnArea")]
+ public CastleSiegeZoneDefinition RawDefenseRespawnArea
+ {
+ get => base.DefenseRespawnArea as CastleSiegeZoneDefinition;
+ set => base.DefenseRespawnArea = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition DefenseRespawnArea
+ {
+ get => base.DefenseRespawnArea;
+ set => base.DefenseRespawnArea = value;
+ }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("attackRespawnArea")]
+ public CastleSiegeZoneDefinition RawAttackRespawnArea
+ {
+ get => base.AttackRespawnArea as CastleSiegeZoneDefinition;
+ set => base.AttackRespawnArea = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition AttackRespawnArea
+ {
+ get => base.AttackRespawnArea;
+ set => base.AttackRespawnArea = value;
+ }
+
+ ///
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ var clone = new CastleSiegeConfiguration();
+ clone.AssignValuesOf(this, gameConfiguration);
+ return clone;
+ }
+
+ ///
+ public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ base.AssignValuesOf(other, gameConfiguration);
+ this.Id = other.GetId();
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeConfiguration Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeData.Generated.cs b/src/Persistence/BasicModel/CastleSiegeData.Generated.cs
new file mode 100644
index 0000000..7341d24
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeData.Generated.cs
@@ -0,0 +1,67 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeData : MUnique.OpenMU.DataModel.Entities.CastleSiegeData, IIdentifiable, IConvertibleTo
+{
+
+
+
+ ///
+ /// Gets the raw collection of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("npcStates")]
+ public ICollection RawNpcStates { get; } = new List();
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override ICollection NpcStates
+ {
+ get => base.NpcStates ??= new CollectionAdapter(this.RawNpcStates);
+ protected set
+ {
+ this.NpcStates.Clear();
+ foreach (var item in value)
+ {
+ this.NpcStates.Add(item);
+ }
+ }
+ }
+
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeData Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeGuildRegistration.Generated.cs b/src/Persistence/BasicModel/CastleSiegeGuildRegistration.Generated.cs
new file mode 100644
index 0000000..4fb8b29
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeGuildRegistration.Generated.cs
@@ -0,0 +1,46 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeGuildRegistration : MUnique.OpenMU.DataModel.Entities.CastleSiegeGuildRegistration, IIdentifiable, IConvertibleTo
+{
+
+
+
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeGuildRegistration Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeNpcDefinition.Generated.cs b/src/Persistence/BasicModel/CastleSiegeNpcDefinition.Generated.cs
new file mode 100644
index 0000000..47f16d1
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeNpcDefinition.Generated.cs
@@ -0,0 +1,81 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeNpcDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition, IIdentifiable, IConvertibleTo
+{
+
+ ///
+ /// Gets or sets the identifier of this instance.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("monsterDefinition")]
+ public MonsterDefinition RawMonsterDefinition
+ {
+ get => base.MonsterDefinition as MonsterDefinition;
+ set => base.MonsterDefinition = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.MonsterDefinition MonsterDefinition
+ {
+ get => base.MonsterDefinition;
+ set => base.MonsterDefinition = value;
+ }
+
+ ///
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ var clone = new CastleSiegeNpcDefinition();
+ clone.AssignValuesOf(this, gameConfiguration);
+ return clone;
+ }
+
+ ///
+ public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeNpcDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ base.AssignValuesOf(other, gameConfiguration);
+ this.Id = other.GetId();
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeNpcDefinition Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeNpcState.Generated.cs b/src/Persistence/BasicModel/CastleSiegeNpcState.Generated.cs
new file mode 100644
index 0000000..1367d5d
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeNpcState.Generated.cs
@@ -0,0 +1,46 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeNpcState : MUnique.OpenMU.DataModel.Entities.CastleSiegeNpcState, IIdentifiable, IConvertibleTo
+{
+
+
+
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeNpcState Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeStateScheduleEntry.Generated.cs b/src/Persistence/BasicModel/CastleSiegeStateScheduleEntry.Generated.cs
new file mode 100644
index 0000000..adc9f9d
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeStateScheduleEntry.Generated.cs
@@ -0,0 +1,63 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeStateScheduleEntry : MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry, IIdentifiable, IConvertibleTo
+{
+
+ ///
+ /// Gets or sets the identifier of this instance.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ var clone = new CastleSiegeStateScheduleEntry();
+ clone.AssignValuesOf(this, gameConfiguration);
+ return clone;
+ }
+
+ ///
+ public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeStateScheduleEntry other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ base.AssignValuesOf(other, gameConfiguration);
+ this.Id = other.GetId();
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeStateScheduleEntry Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeUpgradeDefinition.Generated.cs b/src/Persistence/BasicModel/CastleSiegeUpgradeDefinition.Generated.cs
new file mode 100644
index 0000000..744589b
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeUpgradeDefinition.Generated.cs
@@ -0,0 +1,63 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeUpgradeDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition, IIdentifiable, IConvertibleTo
+{
+
+ ///
+ /// Gets or sets the identifier of this instance.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ var clone = new CastleSiegeUpgradeDefinition();
+ clone.AssignValuesOf(this, gameConfiguration);
+ return clone;
+ }
+
+ ///
+ public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeUpgradeDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ base.AssignValuesOf(other, gameConfiguration);
+ this.Id = other.GetId();
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeUpgradeDefinition Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/CastleSiegeZoneDefinition.Generated.cs b/src/Persistence/BasicModel/CastleSiegeZoneDefinition.Generated.cs
new file mode 100644
index 0000000..35ef3c8
--- /dev/null
+++ b/src/Persistence/BasicModel/CastleSiegeZoneDefinition.Generated.cs
@@ -0,0 +1,63 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+//------------------------------------------------------------------------------
+//
+// This source code was auto-generated by a roslyn code generator.
+//
+//------------------------------------------------------------------------------
+
+// ReSharper disable All
+
+namespace MUnique.OpenMU.Persistence.BasicModel;
+
+using MUnique.OpenMU.Persistence.Json;
+
+///
+/// A plain implementation of .
+///
+public partial class CastleSiegeZoneDefinition : MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition, IIdentifiable, IConvertibleTo
+{
+
+ ///
+ /// Gets or sets the identifier of this instance.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ var clone = new CastleSiegeZoneDefinition();
+ clone.AssignValuesOf(this, gameConfiguration);
+ return clone;
+ }
+
+ ///
+ public override void AssignValuesOf(MUnique.OpenMU.DataModel.Configuration.CastleSiegeZoneDefinition other, MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
+ {
+ base.AssignValuesOf(other, gameConfiguration);
+ this.Id = other.GetId();
+ }
+
+ ///
+ public override bool Equals(object obj)
+ {
+ var baseObject = obj as IIdentifiable;
+ if (baseObject != null)
+ {
+ return baseObject.Id == this.Id;
+ }
+
+ return base.Equals(obj);
+ }
+
+ ///
+ public override int GetHashCode()
+ {
+ return this.Id.GetHashCode();
+ }
+
+ ///
+ public CastleSiegeZoneDefinition Convert() => this;
+}
diff --git a/src/Persistence/BasicModel/GameConfiguration.Generated.cs b/src/Persistence/BasicModel/GameConfiguration.Generated.cs
index 9460cd4..9562054 100644
--- a/src/Persistence/BasicModel/GameConfiguration.Generated.cs
+++ b/src/Persistence/BasicModel/GameConfiguration.Generated.cs
@@ -484,6 +484,24 @@ public partial class GameConfiguration : MUnique.OpenMU.DataModel.Configuration.
set => base.DuelConfiguration = value;
}
+ ///
+ /// Gets the raw object of .
+ ///
+ [System.Text.Json.Serialization.JsonPropertyName("castleSiegeConfiguration")]
+ public CastleSiegeConfiguration RawCastleSiegeConfiguration
+ {
+ get => base.CastleSiegeConfiguration as CastleSiegeConfiguration;
+ set => base.CastleSiegeConfiguration = value;
+ }
+
+ ///
+ [System.Text.Json.Serialization.JsonIgnore]
+ public override MUnique.OpenMU.DataModel.Configuration.CastleSiegeConfiguration CastleSiegeConfiguration
+ {
+ get => base.CastleSiegeConfiguration;
+ set => base.CastleSiegeConfiguration = value;
+ }
+
///
public override MUnique.OpenMU.DataModel.Configuration.GameConfiguration Clone(MUnique.OpenMU.DataModel.Configuration.GameConfiguration gameConfiguration)
{
diff --git a/src/Persistence/EntityFramework/EntityDataContext.cs b/src/Persistence/EntityFramework/EntityDataContext.cs
index d06f971..1950668 100644
--- a/src/Persistence/EntityFramework/EntityDataContext.cs
+++ b/src/Persistence/EntityFramework/EntityDataContext.cs
@@ -21,6 +21,16 @@ public class EntityDataContext : ExtendedTypeContext
///
internal GameConfiguration? CurrentGameConfiguration { get; set; }
+ ///
+ /// Gets the persistent Castle Siege state.
+ ///
+ internal DbSet CastleSiegeData => this.Set();
+
+ ///
+ /// Gets the Castle Siege guild registrations.
+ ///
+ internal DbSet CastleSiegeGuildRegistrations => this.Set();
+
///
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -58,6 +68,11 @@ public class EntityDataContext : ExtendedTypeContext
modelBuilder.Entity().Apply();
modelBuilder.Entity().Apply();
modelBuilder.Entity().Apply();
+ modelBuilder.Entity().Apply();
+ modelBuilder.Entity().Apply();
+ modelBuilder.Entity().Apply();
+ modelBuilder.Entity().Apply();
+ modelBuilder.Entity().Apply();
modelBuilder.Entity().Apply();
modelBuilder.Entity().Apply();
modelBuilder.Entity().Apply();
@@ -98,4 +113,4 @@ public class EntityDataContext : ExtendedTypeContext
GuildContext.ConfigureModel(modelBuilder);
FriendContext.ConfigureModel(modelBuilder);
}
-}
\ No newline at end of file
+}
diff --git a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
index 07c6ef8..ff16a74 100644
--- a/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
+++ b/src/Persistence/EntityFramework/EntityFrameworkContextBase.cs
@@ -68,6 +68,55 @@ internal class EntityFrameworkContextBase : IContext
///
public async ValueTask SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ // A player's entities can be mutated by game logic on a flow that is not serialized against
+ // this save (for example item destruction on an attacker's thread during combat). Such a
+ // concurrent mutation makes change detection throw while it enumerates a tracked collection.
+ // The mutation is a single, quick operation, so a bounded retry lands on a stable moment
+ // instead of failing the whole save - which would otherwise leave the session unpersisted and
+ // roll the player back on relog.
+ const int maxAttempts = 3;
+ var attempt = 0;
+ while (true)
+ {
+ attempt++;
+ try
+ {
+ return await this.SaveChangesCoreAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (attempt < maxAttempts && IsTransientConcurrencyConflict(ex))
+ {
+ this._logger.LogWarning(ex, "Transient concurrency conflict while saving (attempt {Attempt}/{MaxAttempts}); retrying.", attempt, maxAttempts);
+ await Task.Delay(attempt * 10, cancellationToken).ConfigureAwait(false);
+ }
+ }
+ }
+
+ ///
+ /// Determines whether the exception is a transient conflict caused by a concurrent entity mutation
+ /// racing this save, and is therefore worth retrying.
+ ///
+ /// The exception thrown by the save.
+ /// true if the save should be retried.
+ private static bool IsTransientConcurrencyConflict(Exception exception)
+ {
+ // A concurrent entity mutation racing this save corrupts the change tracker mid-enumeration.
+ // Depending on exactly where change detection was, it surfaces as one of several types - a
+ // modified collection (InvalidOperationException), a transiently-null internal key
+ // (ArgumentNullException/NullReferenceException), or an out-of-range index. All are transient:
+ // the racing mutation is a single quick operation, so a bounded retry lands on a stable moment.
+ // A genuinely persistent error of the same type is not masked - it rethrows once the retries
+ // are exhausted. The deterministic serialization (per-player persistence lock) is the primary
+ // guard; this retry only needs to absorb the rare, bursty sources that lock isn't held for.
+ return exception is DbUpdateConcurrencyException
+ or InvalidOperationException
+ or ArgumentNullException
+ or NullReferenceException
+ or IndexOutOfRangeException
+ or KeyNotFoundException;
+ }
+
+ private async ValueTask SaveChangesCoreAsync(CancellationToken cancellationToken)
{
using var l = await this._lock.LockAsync();
@@ -252,6 +301,14 @@ internal class EntityFrameworkContextBase : IContext
GC.SuppressFinalize(this);
}
+ ///
+ /// Determines whether changes of an entity type are published as configuration changes.
+ ///
+ /// The entity type.
+ /// when the entity belongs to the configuration schema.
+ internal static bool PublishesConfigurationChanges(IReadOnlyEntityType entityType)
+ => entityType.GetSchema() == SchemaNames.Configuration;
+
///
/// Releases unmanaged and - optionally - managed resources.
///
@@ -343,7 +400,9 @@ internal class EntityFrameworkContextBase : IContext
}
var changedEntries = this.Context.ChangeTracker.Entries()
- .Where(entity => entity.State != EntityState.Unchanged).ToList();
+ .Where(entity => entity.State != EntityState.Unchanged
+ && PublishesConfigurationChanges(entity.Metadata))
+ .ToList();
foreach (var entry in changedEntries)
{
var (parent, parentCollectionNavigation) = this.GetParentInformation(entry);
@@ -413,4 +472,4 @@ internal class EntityFrameworkContextBase : IContext
return (parent ?? parentId, parentCollectionNavigation);
}
-}
\ No newline at end of file
+}
diff --git a/src/Persistence/EntityFramework/Extensions/ModelBuilder/CastleSiegeExtensions.cs b/src/Persistence/EntityFramework/Extensions/ModelBuilder/CastleSiegeExtensions.cs
new file mode 100644
index 0000000..d01cf7d
--- /dev/null
+++ b/src/Persistence/EntityFramework/Extensions/ModelBuilder/CastleSiegeExtensions.cs
@@ -0,0 +1,85 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Persistence.EntityFramework.Extensions.ModelBuilder;
+
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using MUnique.OpenMU.Persistence.EntityFramework.Model;
+
+///
+/// Extensions for Castle Siege-related s.
+///
+internal static class CastleSiegeExtensions
+{
+ ///
+ /// Applies the settings for the entity.
+ ///
+ /// The builder.
+ public static void Apply(this EntityTypeBuilder builder)
+ {
+ builder.Property(configuration => configuration.CrownHoldTimeSeconds).HasDefaultValue(30);
+ builder.Property(configuration => configuration.RegisterMinLevel).HasDefaultValue(200);
+ builder.Property(configuration => configuration.RegisterMinMembers).HasDefaultValue(20);
+ builder.Property(configuration => configuration.MaxAttackingGuilds).HasDefaultValue(3);
+
+ builder.HasOne(configuration => configuration.RawCastleSiegeMapDefinition)
+ .WithMany()
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(configuration => configuration.RawLandOfTrialsMapDefinition)
+ .WithMany()
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasOne(configuration => configuration.RawRewardItemDefinition)
+ .WithMany()
+ .OnDelete(DeleteBehavior.Restrict);
+ }
+
+ ///
+ /// Applies the settings for the entity.
+ ///
+ /// The builder.
+ public static void Apply(this EntityTypeBuilder builder)
+ {
+ builder.HasOne(definition => definition.RawMonsterDefinition)
+ .WithMany()
+ .IsRequired()
+ .OnDelete(DeleteBehavior.Restrict);
+ builder.HasIndex(definition => new { definition.MonsterDefinitionId, definition.InstanceId });
+ }
+
+ ///
+ /// Applies the settings for the entity.
+ ///
+ /// The builder.
+ public static void Apply(this EntityTypeBuilder builder)
+ {
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(data => data.OwnerGuildId)
+ .OnDelete(DeleteBehavior.SetNull);
+ }
+
+ ///
+ /// Applies the settings for the entity.
+ ///
+ /// The builder.
+ public static void Apply(this EntityTypeBuilder builder)
+ {
+ builder.HasIndex(state => new { state.MonsterNumber, state.InstanceId }).IsUnique();
+ }
+
+ ///
+ /// Applies the settings for the entity.
+ ///
+ /// The builder.
+ public static void Apply(this EntityTypeBuilder builder)
+ {
+ builder.Property(registration => registration.GuildName).HasMaxLength(8).IsRequired();
+ builder.HasIndex(registration => registration.GuildId).IsUnique();
+ builder.HasOne()
+ .WithMany()
+ .HasForeignKey(registration => registration.GuildId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/Persistence/EntityFramework/MUnique.OpenMU.Persistence.EntityFramework.csproj b/src/Persistence/EntityFramework/MUnique.OpenMU.Persistence.EntityFramework.csproj
index 4d32df0..1711a6d 100644
--- a/src/Persistence/EntityFramework/MUnique.OpenMU.Persistence.EntityFramework.csproj
+++ b/src/Persistence/EntityFramework/MUnique.OpenMU.Persistence.EntityFramework.csproj
@@ -48,8 +48,15 @@
+
-
+
diff --git a/src/Persistence/EntityFramework/Migrations/20260710205741_AddIsQuestItemFlag.cs b/src/Persistence/EntityFramework/Migrations/20260710205741_AddIsQuestItemFlag.cs
index dcdc8bd..b6ea120 100644
--- a/src/Persistence/EntityFramework/Migrations/20260710205741_AddIsQuestItemFlag.cs
+++ b/src/Persistence/EntityFramework/Migrations/20260710205741_AddIsQuestItemFlag.cs
@@ -1,9 +1,13 @@
-using Microsoft.EntityFrameworkCore.Migrations;
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
+ using Microsoft.EntityFrameworkCore.Migrations;
+
///
public partial class AddIsQuestItemFlag : Migration
{
diff --git a/src/Persistence/EntityFramework/Migrations/20260712014203_AddBuff.cs b/src/Persistence/EntityFramework/Migrations/20260712014203_AddBuff.cs
index acac7e4..9e6441e 100644
--- a/src/Persistence/EntityFramework/Migrations/20260712014203_AddBuff.cs
+++ b/src/Persistence/EntityFramework/Migrations/20260712014203_AddBuff.cs
@@ -1,10 +1,14 @@
-using System;
-using Microsoft.EntityFrameworkCore.Migrations;
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
#nullable disable
namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
{
+ using System;
+ using Microsoft.EntityFrameworkCore.Migrations;
+
///
public partial class AddBuff : Migration
{
@@ -20,7 +24,7 @@ namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
MagicEffectDefinitionId = table.Column(type: "uuid", nullable: true),
MonsterDefinitionId = table.Column(type: "uuid", nullable: true),
MinimumLevel = table.Column(type: "integer", nullable: true),
- MaximumLevel = table.Column(type: "integer", nullable: true)
+ MaximumLevel = table.Column(type: "integer", nullable: true),
},
constraints: table =>
{
diff --git a/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.Designer.cs
new file mode 100644
index 0000000..ffe119e
--- /dev/null
+++ b/src/Persistence/EntityFramework/Migrations/20260730194321_AddCastleSiege.Designer.cs
@@ -0,0 +1,5725 @@
+//
+using System;
+using MUnique.OpenMU.Persistence.EntityFramework;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
+{
+ [DbContext(typeof(EntityDataContext))]
+ [Migration("20260730194321_AddCastleSiege")]
+ partial class AddCastleSiege
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatBanUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EMail")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsBot")
+ .HasColumnType("boolean");
+
+ b.Property("IsTemplate")
+ .HasColumnType("boolean");
+
+ b.Property("IsVaultExtended")
+ .HasColumnType("boolean");
+
+ b.Property("LanguageIsoCode")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(3)
+ .HasColumnType("character varying(3)")
+ .HasDefaultValue("en");
+
+ b.Property("LoginName")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RegistrationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SecurityCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("TimeZone")
+ .HasColumnType("smallint");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid");
+
+ b.Property("VaultPassword")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LoginName")
+ .IsUnique();
+
+ b.HasIndex("VaultId")
+ .IsUnique();
+
+ b.ToTable("Account", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.HasKey("AccountId", "CharacterClassId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AccountCharacterClass", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("FullAncientSetEquipped")
+ .HasColumnType("boolean");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AppearanceData", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DelayBetweenHits")
+ .HasColumnType("interval");
+
+ b.Property("DelayPerOneDistance")
+ .HasColumnType("interval");
+
+ b.Property("EffectRange")
+ .HasColumnType("integer");
+
+ b.Property("FrustumDistance")
+ .HasColumnType("real");
+
+ b.Property("FrustumEndWidth")
+ .HasColumnType("real");
+
+ b.Property("FrustumStartWidth")
+ .HasColumnType("real");
+
+ b.Property("HitChancePerDistanceMultiplier")
+ .HasColumnType("real");
+
+ b.Property("MaximumNumberOfHitsPerAttack")
+ .HasColumnType("integer");
+
+ b.Property("MaximumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("MinimumNumberOfHitsPerAttack")
+ .HasColumnType("integer");
+
+ b.Property("MinimumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("ProjectileCount")
+ .HasColumnType("integer");
+
+ b.Property("TargetAreaDiameter")
+ .HasColumnType("real");
+
+ b.Property("UseDeferredHits")
+ .HasColumnType("boolean");
+
+ b.Property("UseFrustumFilter")
+ .HasColumnType("boolean");
+
+ b.Property("UseTargetAreaFilter")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("AreaSkillSettings", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Designation")
+ .HasColumnType("text");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("MaximumValue")
+ .HasColumnType("real");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.ToTable("AttributeDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AggregateType")
+ .HasColumnType("integer");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("InputAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("InputOperand")
+ .HasColumnType("real");
+
+ b.Property("InputOperator")
+ .HasColumnType("integer");
+
+ b.Property("OperandAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("PowerUpDefinitionValueId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("TargetAttributeId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("InputAttributeId");
+
+ b.HasIndex("OperandAttributeId");
+
+ b.HasIndex("PowerUpDefinitionValueId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("TargetAttributeId");
+
+ b.ToTable("AttributeRelationship", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("GameMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("ItemDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MinimumValue")
+ .HasColumnType("integer");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId1")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttributeId");
+
+ b.HasIndex("GameMapDefinitionId");
+
+ b.HasIndex("ItemDefinitionId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("SkillId1");
+
+ b.ToTable("AttributeRequirement", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("GroundId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("LeftTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("RightGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("RightTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("RightTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroundId")
+ .IsUnique();
+
+ b.HasIndex("LeftGoalId")
+ .IsUnique();
+
+ b.HasIndex("RightGoalId")
+ .IsUnique();
+
+ b.ToTable("BattleZoneDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Buff", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("MagicEffectDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MaximumLevel")
+ .HasColumnType("integer");
+
+ b.Property("MinimumLevel")
+ .HasColumnType("integer");
+
+ b.Property("MonsterDefinitionId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MagicEffectDefinitionId")
+ .IsUnique();
+
+ b.HasIndex("MonsterDefinitionId");
+
+ b.ToTable("Buff", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttackRespawnAreaId")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("CrownHoldTimeSeconds")
+ .HasColumnType("integer");
+
+ b.Property("DefenseRespawnAreaId")
+ .HasColumnType("uuid");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("GateBuyPrice")
+ .HasColumnType("integer");
+
+ b.Property("GuildScoreCastleSiege")
+ .HasColumnType("integer");
+
+ b.Property("GuildScoreCastleSiegeMembers")
+ .HasColumnType("integer");
+
+ b.Property("LandOfTrialsMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MaxAttackingGuilds")
+ .HasColumnType("integer");
+
+ b.Property("ParticipantRewardMinSeconds")
+ .HasColumnType("integer");
+
+ b.Property("RegisterMinLevel")
+ .HasColumnType("integer");
+
+ b.Property("RegisterMinMembers")
+ .HasColumnType("integer");
+
+ b.Property("RewardItemDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("StatueBuyPrice")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttackRespawnAreaId")
+ .IsUnique();
+
+ b.HasIndex("CastleSiegeMapDefinitionId");
+
+ b.HasIndex("DefenseRespawnAreaId")
+ .IsUnique();
+
+ b.HasIndex("LandOfTrialsMapDefinitionId");
+
+ b.HasIndex("RewardItemDefinitionId");
+
+ b.ToTable("CastleSiegeConfiguration", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeData", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("IsHuntZoneEnabled")
+ .HasColumnType("boolean");
+
+ b.Property("IsOccupied")
+ .HasColumnType("boolean");
+
+ b.Property("OwnerGuildId")
+ .HasColumnType("uuid");
+
+ b.Property("TaxChaos")
+ .HasColumnType("smallint");
+
+ b.Property("TaxHunt")
+ .HasColumnType("integer");
+
+ b.Property("TaxStore")
+ .HasColumnType("smallint");
+
+ b.Property("TributeMoney")
+ .HasColumnType("bigint");
+
+ b.HasKey("Id");
+
+ b.ToTable("CastleSiegeData", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("DefaultSide")
+ .HasColumnType("smallint");
+
+ b.Property("Direction")
+ .HasColumnType("integer");
+
+ b.Property("InstanceId")
+ .HasColumnType("smallint");
+
+ b.Property("IsPersistedToDatabase")
+ .HasColumnType("boolean");
+
+ b.Property("MonsterDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("SpawnX")
+ .HasColumnType("smallint");
+
+ b.Property("SpawnY")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CastleSiegeConfigurationId");
+
+ b.HasIndex("MonsterDefinitionId");
+
+ b.ToTable("CastleSiegeNpcDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeNpcState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeDataId")
+ .HasColumnType("uuid");
+
+ b.Property("CurrentHp")
+ .HasColumnType("integer");
+
+ b.Property("DefenseLevel")
+ .HasColumnType("smallint");
+
+ b.Property("InstanceId")
+ .HasColumnType("smallint");
+
+ b.Property("LifeLevel")
+ .HasColumnType("smallint");
+
+ b.Property("MonsterNumber")
+ .HasColumnType("smallint");
+
+ b.Property("RegenLevel")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CastleSiegeDataId");
+
+ b.ToTable("CastleSiegeNpcState", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeStateScheduleEntry", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("DayOfWeek")
+ .HasColumnType("integer");
+
+ b.Property("Hour")
+ .HasColumnType("smallint");
+
+ b.Property("Minute")
+ .HasColumnType("smallint");
+
+ b.Property("State")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CastleSiegeConfigurationId");
+
+ b.ToTable("CastleSiegeStateScheduleEntry", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeUpgradeDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId1")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId2")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId3")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId4")
+ .HasColumnType("uuid");
+
+ b.Property("Level")
+ .HasColumnType("smallint");
+
+ b.Property("RequiredJewelOfGuardianCount")
+ .HasColumnType("integer");
+
+ b.Property("RequiredZen")
+ .HasColumnType("integer");
+
+ b.Property("Value")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CastleSiegeConfigurationId");
+
+ b.HasIndex("CastleSiegeConfigurationId1");
+
+ b.HasIndex("CastleSiegeConfigurationId2");
+
+ b.HasIndex("CastleSiegeConfigurationId3");
+
+ b.HasIndex("CastleSiegeConfigurationId4");
+
+ b.ToTable("CastleSiegeUpgradeDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CastleSiegeZoneDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("CastleSiegeConfigurationId1")
+ .HasColumnType("uuid");
+
+ b.Property("X1")
+ .HasColumnType("smallint");
+
+ b.Property("X2")
+ .HasColumnType("smallint");
+
+ b.Property("Y1")
+ .HasColumnType("smallint");
+
+ b.Property("Y2")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CastleSiegeConfigurationId");
+
+ b.HasIndex("CastleSiegeConfigurationId1");
+
+ b.ToTable("CastleSiegeZoneDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterSlot")
+ .HasColumnType("smallint");
+
+ b.Property("CharacterStatus")
+ .HasColumnType("integer");
+
+ b.Property("CreateDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentMapId")
+ .HasColumnType("uuid");
+
+ b.Property("Experience")
+ .HasColumnType("bigint");
+
+ b.Property("InventoryExtensions")
+ .HasColumnType("integer");
+
+ b.Property("InventoryId")
+ .HasColumnType("uuid");
+
+ b.Property("IsStoreOpened")
+ .HasColumnType("boolean");
+
+ b.Property("KeyConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("LevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MasterExperience")
+ .HasColumnType("bigint");
+
+ b.Property("MasterLevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MuHelperConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PlayerKillCount")
+ .HasColumnType("integer");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.Property("PositionX")
+ .HasColumnType("smallint");
+
+ b.Property("PositionY")
+ .HasColumnType("smallint");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("StateRemainingSeconds")
+ .HasColumnType("integer");
+
+ b.Property("StoreName")
+ .HasColumnType("text");
+
+ b.Property("UsedFruitPoints")
+ .HasColumnType("integer");
+
+ b.Property("UsedNegFruitPoints")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("CurrentMapId");
+
+ b.HasIndex("InventoryId")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Character", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CanGetCreated")
+ .HasColumnType("boolean");
+
+ b.Property("ComboDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("CreationAllowedFlag")
+ .HasColumnType("smallint");
+
+ b.Property("FruitCalculation")
+ .HasColumnType("integer");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("HomeMapId")
+ .HasColumnType("uuid");
+
+ b.Property("IsMasterClass")
+ .HasColumnType("boolean");
+
+ b.Property("LevelRequirementByCreation")
+ .HasColumnType("smallint");
+
+ b.Property("LevelWarpRequirementReductionPercent")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NextGenerationClassId")
+ .HasColumnType("uuid");
+
+ b.Property("Number")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComboDefinitionId")
+ .IsUnique();
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("HomeMapId");
+
+ b.HasIndex("NextGenerationClassId");
+
+ b.ToTable("CharacterClass", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b =>
+ {
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("DropItemGroupId")
+ .HasColumnType("uuid");
+
+ b.HasKey("CharacterId", "DropItemGroupId");
+
+ b.HasIndex("DropItemGroupId");
+
+ b.ToTable("CharacterDropItemGroup", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActiveQuestId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientActionPerformed")
+ .HasColumnType("boolean");
+
+ b.Property("Group")
+ .HasColumnType("smallint");
+
+ b.Property("LastFinishedQuestId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActiveQuestId");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("LastFinishedQuestId");
+
+ b.ToTable("CharacterQuestState", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClientCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ClientTimeout")
+ .HasColumnType("interval");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MaximumConnections")
+ .HasColumnType("integer");
+
+ b.Property("RoomCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ServerId")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.ToTable("ChatServerDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property