diff --git a/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
new file mode 100644
index 0000000..62a874a
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs
@@ -0,0 +1,44 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// Configuration for the Castle Siege cycle timings.
+/// Rides on the plugin custom-configuration system (no dedicated database table in P1).
+///
+public class CastleSiegeConfiguration
+{
+ ///
+ /// Gets or sets the times of day at which a new cycle opens registration.
+ /// Empty by default; admins start cycles manually via chat command in P1.
+ ///
+ public IList RegistrationOpenTimes { get; set; } = new List();
+
+ /// Gets or sets how long the registration phase lasts.
+ public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5);
+
+ /// Gets or sets how long the preparation phase lasts.
+ public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2);
+
+ /// Gets or sets how long the siege phase lasts.
+ public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Returns true if falls within a 5-second window of any configured
+ /// registration-open time.
+ ///
+ /// The current UTC time.
+ public bool IsRegistrationOpenTime(DateTime now)
+ {
+ if (this.RegistrationOpenTimes.Count == 0)
+ {
+ return false;
+ }
+
+ var nowTime = TimeOnly.FromDateTime(now);
+ var earlier = nowTime.Add(TimeSpan.FromSeconds(-5));
+ return this.RegistrationOpenTimes.Any(p => p.IsBetween(earlier, nowTime));
+ }
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegeContext.cs b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
new file mode 100644
index 0000000..ff72647
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegeContext.cs
@@ -0,0 +1,132 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// In-memory Castle Siege phase state machine (P1 skeleton: no battle/persistence).
+/// Time is injected via method parameters so it can be tested deterministically.
+///
+public class CastleSiegeContext
+{
+ private readonly List _registeredGuilds = new();
+ private DateTime _phaseStartedUtc;
+
+ /// Initializes a new instance of the class.
+ /// The cycle timing configuration.
+ public CastleSiegeContext(CastleSiegeConfiguration configuration)
+ {
+ this.Configuration = configuration;
+ this.Phase = CastleSiegePhase.Ownership;
+ }
+
+ /// Raised after the phase changes. Argument is the new phase.
+ public event Action? PhaseChanged;
+
+ /// Gets the configuration.
+ public CastleSiegeConfiguration Configuration { get; }
+
+ /// Gets the current phase.
+ public CastleSiegePhase Phase { get; private set; }
+
+ /// Gets the current owner guild name, or null if unowned.
+ public string? OwnerGuildName { get; private set; }
+
+ /// Gets the guild names registered for the current cycle.
+ public IReadOnlyList RegisteredGuilds => this._registeredGuilds;
+
+ /// Advances the state machine based on the current time.
+ /// The current UTC time.
+ public ValueTask TickAsync(DateTime now)
+ {
+ switch (this.Phase)
+ {
+ case CastleSiegePhase.Ownership:
+ if (this.Configuration.IsRegistrationOpenTime(now))
+ {
+ return this.ForceStartRegistrationAsync(now);
+ }
+
+ break;
+ case CastleSiegePhase.Registration:
+ if (now >= this._phaseStartedUtc + this.Configuration.RegistrationDuration)
+ {
+ return this.TransitionAsync(CastleSiegePhase.Preparation, now);
+ }
+
+ break;
+ case CastleSiegePhase.Preparation:
+ if (now >= this._phaseStartedUtc + this.Configuration.PreparationDuration)
+ {
+ return this.TransitionAsync(CastleSiegePhase.Siege, now);
+ }
+
+ break;
+ case CastleSiegePhase.Siege:
+ if (now >= this._phaseStartedUtc + this.Configuration.SiegeDuration)
+ {
+ return this.TransitionAsync(CastleSiegePhase.Settlement, now);
+ }
+
+ break;
+ case CastleSiegePhase.Settlement:
+ // P1: no battle -> no winner determination yet. Settle immediately back to ownership.
+ return this.TransitionAsync(CastleSiegePhase.Ownership, now);
+ default:
+ break;
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ /// Admin: forces the cycle into registration now (from any phase).
+ /// The current UTC time.
+ public ValueTask ForceStartRegistrationAsync(DateTime now)
+ {
+ this._registeredGuilds.Clear();
+ return this.TransitionAsync(CastleSiegePhase.Registration, now);
+ }
+
+ /// Admin: forces a specific phase now.
+ /// The target phase.
+ /// The current UTC time.
+ public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
+ => this.TransitionAsync(phase, now);
+
+ /// Admin: resets to the ownership (resting) phase and clears registrations.
+ /// The current UTC time.
+ public ValueTask ResetAsync(DateTime now)
+ {
+ this._registeredGuilds.Clear();
+ return this.TransitionAsync(CastleSiegePhase.Ownership, now);
+ }
+
+ /// Registers a guild (by name) for the current cycle. No-op outside registration.
+ /// The guild name.
+ public void RegisterGuild(string guildName)
+ {
+ if (this.Phase == CastleSiegePhase.Registration
+ && !this._registeredGuilds.Contains(guildName))
+ {
+ this._registeredGuilds.Add(guildName);
+ }
+ }
+
+ /// Admin: sets (or clears) the current owner guild name.
+ /// The owner guild name, or null to clear.
+ public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
+
+ /// 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)}]";
+
+ private ValueTask TransitionAsync(CastleSiegePhase phase, DateTime now)
+ {
+ this.Phase = phase;
+ this._phaseStartedUtc = now;
+ this.PhaseChanged?.Invoke(phase);
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/src/GameLogic/CastleSiege/CastleSiegePhase.cs b/src/GameLogic/CastleSiege/CastleSiegePhase.cs
new file mode 100644
index 0000000..0317868
--- /dev/null
+++ b/src/GameLogic/CastleSiege/CastleSiegePhase.cs
@@ -0,0 +1,26 @@
+//
+// 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/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
new file mode 100644
index 0000000..a03311d
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs
@@ -0,0 +1,76 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests.CastleSiege;
+
+using MUnique.OpenMU.GameLogic.CastleSiege;
+
+///
+/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
+///
+[TestFixture]
+public class CastleSiegeContextTest
+{
+ private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
+
+ /// Tests that a fresh context starts in the ownership (resting) phase.
+ [Test]
+ public void StartsInOwnership()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
+ }
+
+ /// Tests that force-starting moves the state machine into registration.
+ [Test]
+ public async Task ForceStartMovesToRegistrationAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
+ }
+
+ /// Tests that registration advances to preparation once its duration elapses.
+ [Test]
+ public async Task RegistrationAdvancesToPreparationAfterDurationAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ await ctx.TickAsync(T0.AddMinutes(4)); // still within registration
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration));
+ await ctx.TickAsync(T0.AddMinutes(5)); // registration duration elapsed
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Preparation));
+ }
+
+ /// Tests a full cycle: registration -> preparation -> siege -> settlement -> ownership.
+ [Test]
+ public async Task FullCycleReturnsToOwnershipAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ await ctx.TickAsync(T0.AddMinutes(5)); // -> Preparation
+ await ctx.TickAsync(T0.AddMinutes(7)); // +2 prep -> Siege
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Siege));
+ await ctx.TickAsync(T0.AddMinutes(17)); // +10 siege -> Settlement
+ await ctx.TickAsync(T0.AddMinutes(17)); // Settlement -> Ownership
+ Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
+ }
+
+ /// Tests that guilds can be registered (by name) during the registration phase.
+ [Test]
+ public async Task RegisterGuildCollectsNamesDuringRegistrationAsync()
+ {
+ var ctx = new CastleSiegeContext(Config());
+ await ctx.ForceStartRegistrationAsync(T0);
+ ctx.RegisterGuild("Attackers");
+ Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
+ }
+
+ private static CastleSiegeConfiguration Config() => new()
+ {
+ RegistrationDuration = TimeSpan.FromMinutes(5),
+ PreparationDuration = TimeSpan.FromMinutes(2),
+ SiegeDuration = TimeSpan.FromMinutes(10),
+ };
+}