diff --git a/docs/superpowers/plans/2026-07-14-cs-p1-phase-statemachine.md b/docs/superpowers/plans/2026-07-14-cs-p1-phase-statemachine.md new file mode 100644 index 0000000..de6b85b --- /dev/null +++ b/docs/superpowers/plans/2026-07-14-cs-p1-phase-statemachine.md @@ -0,0 +1,772 @@ +# CS P1 — Faz State Machine + Zamanlama + Admin — Uygulama Planı + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Castle Siege'in iskeletini kurmak: bellekte bir faz state machine (Ownership→Registration→Preparation→Siege→Settlement→Ownership), OpenMU'nun saniyelik periyodik-task runner'ıyla zaman-güdümlü ilerleyen, ve tamamen admin (GM chat komutları) kontrolünde. Savaş, harita, kalıcılık ve gerçek sahiplik YOK (sonraki fazlar). + +**Architecture:** Yeni `CastleSiegeContext` faz state machine'ini bellekte tutar; `TickAsync(DateTime now)` ile config sürelerine göre faz ilerletir (test için `now` enjekte edilir). İnce `CastleSiegeEventPlugIn : IPeriodicTaskPlugIn` her saniye context'i tick'ler ve custom config taşır (`ISupportCustomConfiguration` — yeni DB tablosu/migration YOK). GM chat komutları context'i zorlar. Durum per-GameContext static sözlükte (core dosyalara dokunmadan, tamamen additive). + +**Tech Stack:** .NET 10, C#, OpenMU PlugIn sistemi, NUnit. + +## Global Constraints + +- Depo: `d:/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw` (bash'te tırnak içinde). +- Tüm P1 kodu **additive** (yeni dosyalar) — core dosyalara dokunma; `// ADAMU-CUSTOM` gerekmez (yeni dosya = zaten bizim). +- Lokal derleme daima `-p:ci=true`. +- `DateTime.UtcNow` doğrudan state machine mantığında KULLANILMAZ; `TickAsync(DateTime now)` parametresi alır (test edilebilirlik). Plugin `DateTime.UtcNow` geçer. +- Commit yazarı: `Acentech Dev `. +- P1 sahipliği bellekte `OwnerGuildName` (string?) olarak tutar — guild EF referansı YOK (P3/P4). + +--- + +### Task 1: Faz enum + config + state machine core + testler + +**Files:** +- Create: `src/GameLogic/CastleSiege/CastleSiegePhase.cs` +- Create: `src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs` +- Create: `src/GameLogic/CastleSiege/CastleSiegeContext.cs` +- Test: `tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs` + +**Interfaces:** +- Produces: `CastleSiegePhase` enum; `CastleSiegeConfiguration` (durations + timetable); `CastleSiegeContext` with `Phase`, `OwnerGuildName`, `RegisteredGuilds`, `TickAsync(DateTime now)`, `ForceStartRegistration(DateTime now)`, `ForcePhase(CastleSiegePhase, DateTime now)`, `RegisterGuild(string)`, `SetOwner(string?)`, `Reset(DateTime now)`, `GetStatusText()`. + +- [ ] **Step 1: Faz enum'unu yaz** + +Create `src/GameLogic/CastleSiege/CastleSiegePhase.cs`: +```csharp +// +// 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, +} +``` + +- [ ] **Step 2: Config sınıfını yaz** + +Create `src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs`: +```csharp +// +// 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. + /// + 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)); + } +} +``` + +- [ ] **Step 3: State machine testini yaz (implementasyondan ÖNCE)** + +Create `tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs`: +```csharp +// +// 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 CastleSiegeConfiguration Config() => new() + { + RegistrationDuration = TimeSpan.FromMinutes(5), + PreparationDuration = TimeSpan.FromMinutes(2), + SiegeDuration = TimeSpan.FromMinutes(10), + }; + + private static readonly DateTime T0 = new(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc); + + [Test] + public void StartsInOwnership() + { + var ctx = new CastleSiegeContext(Config()); + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); + } + + [Test] + public async Task ForceStartMovesToRegistrationAsync() + { + var ctx = new CastleSiegeContext(Config()); + await ctx.ForceStartRegistrationAsync(T0); + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Registration)); + } + + [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)); + } + + [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 -> Ownership + Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership)); + } + + [Test] + public async Task RegisterGuildCollectsNamesDuringRegistrationAsync() + { + var ctx = new CastleSiegeContext(Config()); + await ctx.ForceStartRegistrationAsync(T0); + ctx.RegisterGuild("Attackers"); + Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers")); + } +} +``` + +- [ ] **Step 4: Testin başarısız (derlenmez) olduğunu gör** + +Run: +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +dotnet test ../tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj -c Release -p:ci=true --filter "CastleSiegeContextTest" --nologo 2>&1 | tail -8 +``` +Expected: derleme hatası (`CastleSiegeContext` yok). + +- [ ] **Step 5: State machine'i yaz** + +Create `src/GameLogic/CastleSiege/CastleSiegeContext.cs`: +```csharp +// +// 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; + } +} +``` + +- [ ] **Step 6: Testin geçtiğini gör** + +Run: +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +dotnet test ../tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj -c Release -p:ci=true --filter "CastleSiegeContextTest" --nologo 2>&1 | tail -8 +``` +Expected: `Passed! - Failed: 0, Passed: 5`. + +- [ ] **Step 7: Commit** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw" && \ +git add src/GameLogic/CastleSiege/ tests/MUnique.OpenMU.Tests/CastleSiege/ && \ +git -c user.name="Acentech Dev" -c user.email="acentech_dev@affinitybox.com" \ + commit -m "feat(CS-P1): Castle Siege phase state machine (in-memory, time-injected) + tests" +``` + +--- + +### Task 2: Periyodik-task plugin (saniyelik tick + custom config) + +**Files:** +- Create: `src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs` + +**Interfaces:** +- Consumes: `CastleSiegeContext`, `CastleSiegeConfiguration` (Task 1) +- Produces: `CastleSiegeEventPlugIn` implementing `IPeriodicTaskPlugIn` + `ISupportCustomConfiguration` + `ISupportDefaultCustomConfiguration`; exposes `static CastleSiegeContext? TryGetContext(IGameContext)` so chat commands can reach the per-context state machine without a plugin instance. + +- [ ] **Step 1: Plugin'i yaz** + +Create `src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; + +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.CastleSiege; +using MUnique.OpenMU.PlugIns; + +/// +/// 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). +/// +[PlugIn] +[Display(Name = nameof(CastleSiegeEventPlugIn), Description = "Castle Siege event (P1 skeleton: phase state machine + scheduling).")] +[Guid("6E2C8B41-9A4D-4C2E-9E7B-1F2A3B4C5D60")] +public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration +{ + private static readonly ConcurrentDictionary Contexts = new(); + + /// + public CastleSiegeConfiguration? Configuration { get; set; } + + /// + public object CreateDefaultConfig() => new CastleSiegeConfiguration(); + + /// Gets the Castle Siege context for a game context, if the periodic tick has initialized it. + /// The game context. + /// Static so GM chat commands can reach the state machine without a plugin instance + /// (GetKnownPlugInsOf returns Types, not instances). The tick runs every second, so the + /// context exists within ~1s of server start. + public static CastleSiegeContext? TryGetContext(IGameContext gameContext) + => Contexts.TryGetValue(gameContext, out var context) ? context : null; + + /// + public async ValueTask ExecuteTaskAsync(GameContext gameContext) + { + var context = Contexts.GetOrAdd(gameContext, _ => new CastleSiegeContext(this.Configuration ?? new CastleSiegeConfiguration())); + await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false); + } + + /// + public void ForceStart() + { + // Force-start applies to every active game context's siege. + foreach (var context in Contexts.Values) + { + _ = context.ForceStartRegistrationAsync(DateTime.UtcNow); + } + } +} +``` + +- [ ] **Step 2: Derlemeyi doğrula** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +dotnet build GameLogic/MUnique.OpenMU.GameLogic.csproj -c Release -p:ci=true --nologo -clp:ErrorsOnly 2>&1 | tail -8 +``` +Expected: `Build succeeded` (0 hata). `ISupportCustomConfiguration`/`ISupportDefaultCustomConfiguration` bulunamazsa `using MUnique.OpenMU.PlugIns;` yeterli — imzaları o namespace'te; değilse hatadaki tam namespace'i ekle. + +- [ ] **Step 3: Commit** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw" && \ +git add src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs && \ +git -c user.name="Acentech Dev" -c user.email="acentech_dev@affinitybox.com" \ + commit -m "feat(CS-P1): periodic Castle Siege plugin (per-context tick + custom config)" +``` + +--- + +### Task 3: Admin GM chat komutları + +**Files:** +- Create: `src/GameLogic/PlugIns/ChatCommands/CastleSiegeStatusChatCommandPlugIn.cs` +- Create: `src/GameLogic/PlugIns/ChatCommands/CastleSiegeStartChatCommandPlugIn.cs` +- Create: `src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs` +- Create: `src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs` +- Create: `src/GameLogic/PlugIns/ChatCommands/CastleSiegeResetChatCommandPlugIn.cs` + +**Interfaces:** +- Consumes: `CastleSiegeEventPlugIn.GetContext` (Task 2) + +- [ ] **Step 1: İki kanonik deseni not et (tüm komutlarda kullanılacak)** + +**Context erişimi** (static — `GetKnownPlugInsOf()` Type döndürür, instance değil; o yüzden static `TryGetContext` kullanıyoruz): +```csharp +var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); +``` + +**Arbitrary-text mesaj** (Player'da düz `ShowMessageAsync(string)` yok; view plugin ile sarılır): +```csharp +await player.InvokeViewPlugInAsync( + p => p.ShowMessageAsync(text, MessageType.BlueNormal)).ConfigureAwait(false); +``` + +**Her komut dosyasının using bloğu şunları içermeli:** `MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks` (TryGetContext), `MUnique.OpenMU.GameLogic.Views` (IShowMessagePlugIn), `MUnique.OpenMU.Interfaces` (MessageType, CharacterStatus), `MUnique.OpenMU.PlugIns` (attributes). `System.Linq` artık gerekmez. + +- [ ] **Step 2: `/csstatus` komutu** + +Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeStatusChatCommandPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// Shows the current Castle Siege status. GM only. +[Guid("A1B2C3D4-0001-4E5F-9A0B-CS0000000001")] +[PlugIn] +[Display(Name = "Castle Siege Status", Description = "GM command: /csstatus")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegeStatusChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/csstatus"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + var text = context?.GetStatusText() ?? "Castle Siege plugin not active."; + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(text, MessageType.BlueNormal)).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 3: `/csstart` komutu** + +Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeStartChatCommandPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// Forces the Castle Siege into the registration phase. GM only. +[Guid("A1B2C3D4-0002-4E5F-9A0B-CS0000000002")] +[PlugIn] +[Display(Name = "Castle Siege Start", Description = "GM command: /csstart")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegeStartChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/csstart"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + await context.ForceStartRegistrationAsync(DateTime.UtcNow).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege: registration started.", MessageType.BlueNormal)).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 4: `/csphase `, `/cssetowner `, `/csreset` komutları** + +Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.CastleSiege; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege. +[Guid("A1B2C3D4-0003-4E5F-9A0B-CS0000000003")] +[PlugIn] +[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase ")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/csphase"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + 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)) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Usage: /csphase ", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false); + } +} +``` + +Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// Sets the Castle Siege owner guild by name. GM only. Usage: /cssetowner GuildName (empty clears). +[Guid("A1B2C3D4-0004-4E5F-9A0B-CS0000000004")] +[PlugIn] +[Display(Name = "Castle Siege Set Owner", Description = "GM command: /cssetowner ")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/cssetowner"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var parts = command.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + var owner = parts.Length >= 2 ? parts[1].Trim() : null; + + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false); + 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); + } +} +``` + +Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeResetChatCommandPlugIn.cs`: +```csharp +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns.ChatCommands; + +using System; +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks; +using MUnique.OpenMU.GameLogic.Views; +using MUnique.OpenMU.Interfaces; +using MUnique.OpenMU.PlugIns; + +/// Resets Castle Siege to the ownership phase and clears registrations. GM only. +[Guid("A1B2C3D4-0005-4E5F-9A0B-CS0000000005")] +[PlugIn] +[Display(Name = "Castle Siege Reset", Description = "GM command: /csreset")] +[ChatCommandHelp(Command, CharacterStatus.GameMaster)] +public class CastleSiegeResetChatCommandPlugIn : IChatCommandPlugIn +{ + private const string Command = "/csreset"; + + /// + public string Key => Command; + + /// + public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster; + + /// + public async ValueTask HandleCommandAsync(Player player, string command) + { + var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext); + if (context is null) + { + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false); + return; + } + + await context.ResetAsync(DateTime.UtcNow).ConfigureAwait(false); + await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync("Castle Siege reset to ownership phase.", MessageType.BlueNormal)).ConfigureAwait(false); + } +} +``` + +- [ ] **Step 5: `ShowMessageAsync` ve `GetKnownPlugInsOf` API'lerini doğrula** + +`player.ShowMessageAsync(string)` OpenMU'da mevcut mu kontrol et (yoksa `player.ShowMessageAsync(msg, MessageType.BlueNormal)` veya `ShowLocalizedBlueMessageAsync` kullan). `PlugInManager.GetKnownPlugInsOf()` mevcut mu kontrol et; değilse mevcut plugin-erişim API'sine göre düzelt (StartBloodCastle `GetStrategy` desenine benzer bir strateji kaydı veya `GetPlugInPoint`). + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +grep -rnE "public .*ShowMessageAsync\(string|GetKnownPlugInsOf" GameLogic/PlayerExtensions.cs GameLogic/Player.cs PlugIns/*.cs 2>/dev/null | head +``` + +- [ ] **Step 6: Derle + gerekiyorsa API'leri düzelt** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +dotnet build GameLogic/MUnique.OpenMU.GameLogic.csproj -c Release -p:ci=true --nologo -clp:ErrorsOnly 2>&1 | tail -12 +``` +Expected: `Build succeeded`. Hata varsa Step 5'teki API isimlerini mevcut koda göre düzelt, tekrar derle. + +- [ ] **Step 7: Commit** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw" && \ +git add src/GameLogic/PlugIns/ChatCommands/CastleSiege*.cs && \ +git -c user.name="Acentech Dev" -c user.email="acentech_dev@affinitybox.com" \ + commit -m "feat(CS-P1): GM chat commands (/csstatus /csstart /csphase /cssetowner /csreset)" +``` + +--- + +### Task 4: Uçtan uca smoke test (çalışan server + admin komutu) + +**Files:** yok (doğrulama) + +- [ ] **Step 1: Tüm test suit'ini koş (regresyon)** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +dotnet test ../tests/MUnique.OpenMU.Tests/MUnique.OpenMU.Tests.csproj -c Release -p:ci=true --filter "CastleSiege|GetNpcByNumber" --nologo 2>&1 | tail -8 +``` +Expected: CS state-machine testleri + remote-NPC testi geçer. + +- [ ] **Step 2: Image'i yeniden derle ve container'ı güncelle** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw/src" && \ +docker build -f Startup/Dockerfile -t adamu-openmu:dev . && \ +cd ../deploy-adamu && docker compose -f docker-compose.local.yml up -d +``` + +- [ ] **Step 3: Emülatörde GM ile `/csstatus` ve `/csstart` çalıştır (manuel)** + +GM yetkili bir karakterle chat'e `/csstatus` yaz → mavi mesajda `CS phase=Ownership, owner=(none), registered=0` görünmeli. `/csstart` → `registration started`; tekrar `/csstatus` → `phase=Registration`. Bu, state machine + plugin + admin zincirinin canlı server'da çalıştığını kanıtlar. + +- [ ] **Step 4: Gitea'ya push** + +```bash +cd "/d/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw" && git push origin main +``` + +--- + +## Sonraki adım + +P1 bitince CS iskeleti canlı: fazlar zamanlamayla/adminle geçiyor, durum sorgulanabiliyor. **P2** = kayıt akışı (Guardsman NPC, guild mark, ücret, min guild şartları) + protokol. Guild EF cross-context referansı ve kalıcılık, sahipliğin gerçek anlam kazandığı P3/P4'te eklenecek (bu plandaki `OwnerGuildName` string yer tutucusu o zaman gerçek guild referansına yükseltilecek).