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

32 KiB
Raw Blame History

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:

// <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:

// <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:

// <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:

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:

// <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:

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
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:

// <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
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
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):

var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);

Arbitrary-text mesaj (Player'da düz ShowMessageAsync(string) yok; view plugin ile sarılır):

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:

// <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:

// <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:

// <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:

// <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:

// <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).

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
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
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)
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
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. /csstartregistration started; tekrar /csstatusphase=Registration. Bu, state machine + plugin + admin zincirinin canlı server'da çalıştığını kanıtlar.

  • Step 4: Gitea'ya push
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).