Files
AdamuSw/docs/superpowers/plans/2026-07-14-cs-p1-phase-statemachine.md
Acentech Dev c2b495c754
Some checks failed
.NET Core / build (push) Has been cancelled
docs: CS P1 plan (phase state machine + scheduling + admin, in-memory)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:33:16 +03:00

773 lines
32 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 <acentech_dev@affinitybox.com>`.
- 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
// <copyright file="CastleSiegePhase.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// The phases of a Castle Siege cycle.
/// </summary>
public enum CastleSiegePhase
{
/// <summary>Resting phase: castle is (un)owned, waiting for the next registration window.</summary>
Ownership,
/// <summary>Guilds can register to attack.</summary>
Registration,
/// <summary>Registration closed; defenders prepare before the siege starts.</summary>
Preparation,
/// <summary>The siege battle is running.</summary>
Siege,
/// <summary>Siege ended; determining the new owner.</summary>
Settlement,
}
```
- [ ] **Step 2: Config sınıfını yaz**
Create `src/GameLogic/CastleSiege/CastleSiegeConfiguration.cs`:
```csharp
// <copyright file="CastleSiegeConfiguration.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// Configuration for the Castle Siege cycle timings.
/// Rides on the plugin custom-configuration system (no dedicated database table in P1).
/// </summary>
public class CastleSiegeConfiguration
{
/// <summary>
/// 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.
/// </summary>
public IList<TimeOnly> RegistrationOpenTimes { get; set; } = new List<TimeOnly>();
/// <summary>Gets or sets how long the registration phase lasts.</summary>
public TimeSpan RegistrationDuration { get; set; } = TimeSpan.FromMinutes(5);
/// <summary>Gets or sets how long the preparation phase lasts.</summary>
public TimeSpan PreparationDuration { get; set; } = TimeSpan.FromMinutes(2);
/// <summary>Gets or sets how long the siege phase lasts.</summary>
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Returns true if <paramref name="now"/> falls within a 5-second window of any configured
/// registration-open time.
/// </summary>
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
// <copyright file="CastleSiegeContextTest.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.CastleSiege;
using MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// Tests for the Castle Siege phase state machine (time-driven, injected clock).
/// </summary>
[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
// <copyright file="CastleSiegeContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.CastleSiege;
/// <summary>
/// In-memory Castle Siege phase state machine (P1 skeleton: no battle/persistence).
/// Time is injected via method parameters so it can be tested deterministically.
/// </summary>
public class CastleSiegeContext
{
private readonly List<string> _registeredGuilds = new();
private DateTime _phaseStartedUtc;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param>
public CastleSiegeContext(CastleSiegeConfiguration configuration)
{
this.Configuration = configuration;
this.Phase = CastleSiegePhase.Ownership;
}
/// <summary>Raised after the phase changes. Argument is the new phase.</summary>
public event Action<CastleSiegePhase>? PhaseChanged;
/// <summary>Gets the configuration.</summary>
public CastleSiegeConfiguration Configuration { get; }
/// <summary>Gets the current phase.</summary>
public CastleSiegePhase Phase { get; private set; }
/// <summary>Gets the current owner guild name, or null if unowned.</summary>
public string? OwnerGuildName { get; private set; }
/// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now)
{
switch (this.Phase)
{
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;
}
/// <summary>Admin: forces the cycle into registration now (from any phase).</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ForceStartRegistrationAsync(DateTime now)
{
this._registeredGuilds.Clear();
return this.TransitionAsync(CastleSiegePhase.Registration, now);
}
/// <summary>Admin: forces a specific phase now.</summary>
/// <param name="phase">The target phase.</param>
/// <param name="now">The current UTC time.</param>
public ValueTask ForcePhaseAsync(CastleSiegePhase phase, DateTime now)
=> this.TransitionAsync(phase, now);
/// <summary>Admin: resets to the ownership (resting) phase and clears registrations.</summary>
/// <param name="now">The current UTC time.</param>
public ValueTask ResetAsync(DateTime now)
{
this._registeredGuilds.Clear();
return this.TransitionAsync(CastleSiegePhase.Ownership, now);
}
/// <summary>Registers a guild (by name) for the current cycle. No-op outside registration.</summary>
/// <param name="guildName">The guild name.</param>
public void RegisterGuild(string guildName)
{
if (this.Phase == CastleSiegePhase.Registration
&& !this._registeredGuilds.Contains(guildName))
{
this._registeredGuilds.Add(guildName);
}
}
/// <summary>Admin: sets (or clears) the current owner guild name.</summary>
/// <param name="guildName">The owner guild name, or null to clear.</param>
public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
/// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "
+ $"registered={this._registeredGuilds.Count} [{string.Join(", ", this._registeredGuilds)}]";
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<CastleSiegeConfiguration>` + `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
// <copyright file="CastleSiegeEventPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Drives the Castle Siege phase state machine: ticks it every second and carries its configuration.
/// State is per-<see cref="IGameContext"/> and kept in memory (P1: no persistence).
/// </summary>
[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<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
{
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
/// <inheritdoc />
public CastleSiegeConfiguration? Configuration { get; set; }
/// <inheritdoc />
public object CreateDefaultConfig() => new CastleSiegeConfiguration();
/// <summary>Gets the Castle Siege context for a game context, if the periodic tick has initialized it.</summary>
/// <param name="gameContext">The game context.</param>
/// <remarks>Static so GM chat commands can reach the state machine without a plugin instance
/// (<c>GetKnownPlugInsOf</c> returns Types, not instances). The tick runs every second, so the
/// context exists within ~1s of server start.</remarks>
public static CastleSiegeContext? TryGetContext(IGameContext gameContext)
=> Contexts.TryGetValue(gameContext, out var context) ? context : null;
/// <inheritdoc />
public async ValueTask ExecuteTaskAsync(GameContext gameContext)
{
var context = Contexts.GetOrAdd(gameContext, _ => new CastleSiegeContext(this.Configuration ?? new CastleSiegeConfiguration()));
await context.TickAsync(DateTime.UtcNow).ConfigureAwait(false);
}
/// <inheritdoc />
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<T>()` 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<IShowMessagePlugIn>(
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
// <copyright file="CastleSiegeStatusChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>Shows the current Castle Siege status. GM only.</summary>
[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";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
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<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal)).ConfigureAwait(false);
}
}
```
- [ ] **Step 3: `/csstart` komutu**
Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeStartChatCommandPlugIn.cs`:
```csharp
// <copyright file="CastleSiegeStartChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>Forces the Castle Siege into the registration phase. GM only.</summary>
[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";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForceStartRegistrationAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege: registration started.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
```
- [ ] **Step 4: `/csphase <phase>`, `/cssetowner <name>`, `/csreset` komutları**
Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegePhaseChatCommandPlugIn.cs`:
```csharp
// <copyright file="CastleSiegePhaseChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>Forces a specific Castle Siege phase. GM only. Usage: /csphase Siege.</summary>
[Guid("A1B2C3D4-0003-4E5F-9A0B-CS0000000003")]
[PlugIn]
[Display(Name = "Castle Siege Phase", Description = "GM command: /csphase <Ownership|Registration|Preparation|Siege|Settlement>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegePhaseChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/csphase";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var parts = command.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (parts.Length < 2 || !Enum.TryParse<CastleSiegePhase>(parts[1], true, out var phase))
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Usage: /csphase <Ownership|Registration|Preparation|Siege|Settlement>", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ForcePhaseAsync(phase, DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege: phase set to {phase}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
```
Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeSetOwnerChatCommandPlugIn.cs`:
```csharp
// <copyright file="CastleSiegeSetOwnerChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>Sets the Castle Siege owner guild by name. GM only. Usage: /cssetowner GuildName (empty clears).</summary>
[Guid("A1B2C3D4-0004-4E5F-9A0B-CS0000000004")]
[PlugIn]
[Display(Name = "Castle Siege Set Owner", Description = "GM command: /cssetowner <guildName>")]
[ChatCommandHelp(Command, CharacterStatus.GameMaster)]
public class CastleSiegeSetOwnerChatCommandPlugIn : IChatCommandPlugIn
{
private const string Command = "/cssetowner";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
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<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
context.SetOwner(string.IsNullOrWhiteSpace(owner) ? null : owner);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync($"Castle Siege owner set to {owner ?? "(none)"}.", MessageType.BlueNormal)).ConfigureAwait(false);
}
}
```
Create `src/GameLogic/PlugIns/ChatCommands/CastleSiegeResetChatCommandPlugIn.cs`:
```csharp
// <copyright file="CastleSiegeResetChatCommandPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
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;
/// <summary>Resets Castle Siege to the ownership phase and clears registrations. GM only.</summary>
[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";
/// <inheritdoc />
public string Key => Command;
/// <inheritdoc/>
public CharacterStatus MinCharacterStatusRequirement => CharacterStatus.GameMaster;
/// <inheritdoc />
public async ValueTask HandleCommandAsync(Player player, string command)
{
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync("Castle Siege plugin not active.", MessageType.BlueNormal)).ConfigureAwait(false);
return;
}
await context.ResetAsync(DateTime.UtcNow).ConfigureAwait(false);
await player.InvokeViewPlugInAsync<IShowMessagePlugIn>(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<T>()` 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).