3 Commits

Author SHA1 Message Date
Acentech Dev
9f2078d724 feat(CS-P3): throne capture + settlement winner in state machine + tests
Some checks failed
.NET Core / build (push) Has been cancelled
2026-07-15 00:48:09 +03:00
Acentech Dev
396527a62a docs: CS P3 siege battle plan (minimal playable increment first) 2026-07-15 00:42:05 +03:00
Acentech Dev
41588bd237 feat(CS-P2): registration fee (zen) + already-registered guard on Guardsman 2026-07-15 00:36:20 +03:00
5 changed files with 179 additions and 2 deletions

View File

@@ -0,0 +1,110 @@
# CS P3 — Kuşatma Savaşı — Uygulama Planı
> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development or superpowers:executing-plans. Steps use checkbox (`- [ ]`) tracking.
**Goal:** Castle Siege'i oynanabilir yapmak. İlk increment: Siege fazı başlayınca kayıtlı guild üyelerini CS haritasına (Valley of Loren) al, orada PvP aç, bir **Taht** ele geçirme noktası koy; kuşatma bitince tahtı tutan guild kazanır ve kale sahibi olur. Kapılar/kristaller/tam S6 protokolü sonraki increment'ler.
**Architecture:** P1 state machine'in Siege fazına giriş/çıkışına kanca takılır (`CastleSiegeContext` faz-değişim event'i zaten var: `PhaseChanged`). Siege başlayınca `CastleSiegeEventPlugIn` (server-side, GameContext erişimli) kayıtlı oyuncuları warp'lar + PvP açar; Taht bir NPC (talk → guild'i "occupier" yapar); Settlement'ta occupier = kazanan → `context.SetOwner`. Reuse: `player.WarpToAsync(ExitGate)`, `Map.Definition.BattleZone` (PvP), `IPlayerTalkToNpcPlugIn` (Taht), P1 `CastleSiegeContext`.
**Tech Stack:** .NET 10, OpenMU GameLogic/PlugIns, NUnit.
## Global Constraints
- Depo: `d:/OpenMU/MU Client_Mobile 1.04d - Season 6E3/AdamuSw` (bash tırnak içinde).
- Additive-first; core dokunuşu `// ADAMU-CUSTOM`.
- Lokal derleme `-p:ci=true`. Commit yazarı `Acentech Dev <acentech_dev@affinitybox.com>`.
- CS map = **Valley of Loren (WorldIndex 30)** — implementation'da `Persistence/Initialization`'dan tam numarayı + entrance gate'i doğrula.
- Zaman/rastgele state machine mantığında enjekte (P1 deseni).
## Investigation notları (implementation başında doğrula)
- Warp: `player.WarpToAsync(ExitGate gate)` (Player.cs:1088). CS map entrance gate'i gerekir — Initialization'da Valley of Loren spawn/exit gate'ini bul veya oluştur.
- PvP: kill izni `GameContext.PvpEnabled || Map.Definition.BattleZone != null || CurrentMiniGame.AllowPlayerKilling` (Player.cs:725). CS map'e Siege sırasında PvP açmak için: ya map Definition'a `BattleZone` ekle, ya da CS'ye özel bir `AllowPlayerKilling` context (MiniGame benzeri). **Karar (impl):** BattleZone en temiz — Valley of Loren Definition'a BattleZone tanımı (Initialization).
- Taht NPC: yeni bir NPC number (S6 CS'de "Crown/Throne switch"). `IPlayerTalkToNpcPlugIn` ile ele geçirme (remote-NPC/Guardsman deseni).
- CastleSiegeContext'e occupier alanı + kazanan mantığı eklenir (P1'de OwnerGuildName string; occupier de string guild adı).
---
### Task 1: State machine — occupier + settlement kazananı
**Files:**
- Modify: `src/GameLogic/CastleSiege/CastleSiegeContext.cs`
- Test: `tests/MUnique.OpenMU.Tests/CastleSiege/CastleSiegeContextTest.cs`
**Interfaces:**
- Produces: `CastleSiegeContext.CaptureThrone(string guildName)` (sadece Siege fazında occupier'ı set eder), `OccupierGuildName` (get), Settlement'ta occupier varsa `OwnerGuildName = occupier`.
- [ ] **Step 1: Test yaz** — Siege fazında `CaptureThrone("A")``OccupierGuildName=="A"`; Settlement tick sonrası `OwnerGuildName=="A"`. Ownership fazında CaptureThrone no-op.
```csharp
[Test]
public async Task ThroneCaptureDuringSiegeBecomesOwnerOnSettlementAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.CaptureThrone("Attackers");
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership (owner atanir)
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
}
```
- [ ] **Step 2: Çalıştır → fail (CaptureThrone/OccupierGuildName yok).**
- [ ] **Step 3: Implement**`CastleSiegeContext`'e:
- `private string? _occupier;` + `public string? OccupierGuildName => this._occupier;`
- `public void CaptureThrone(string g){ if(this.Phase==CastleSiegePhase.Siege) this._occupier = g; }`
- `ForceStartRegistrationAsync`/`ResetAsync`'te `_occupier=null`.
- Settlement→Ownership geçişinde (TickAsync Settlement case): `if(this._occupier is {} occ){ this.SetOwner(occ); } this._occupier=null;` (TransitionAsync'ten önce).
- [ ] **Step 4: Test geçsin.** **Step 5: Commit.**
---
### Task 2: Siege başlarken warp + PvP; bitince güvenli bölgeye al
**Files:**
- Modify: `src/GameLogic/PlugIns/PeriodicTasks/CastleSiegeEventPlugIn.cs`
- (Belki) Create: `src/GameLogic/CastleSiege/CastleSiegeMapController.cs` (warp/PvP yardımcıları)
**Interfaces:**
- Consumes: `CastleSiegeContext.PhaseChanged` (P1), `player.WarpToAsync`, CS map (Valley of Loren).
- Produces: Siege fazına girince kayıtlı guild üyeleri CS map'e warp; Ownership'e dönünce map'ten güvenli bölgeye.
- [ ] **Step 1:** `CastleSiegeEventPlugIn.ExecuteTaskAsync` içinde faz değişimini yakala (context.Phase önceki-değerle karşılaştır ya da `PhaseChanged` event'ine abone ol). Siege'e GİRİŞTE: `gameContext`'teki tüm oyunculardan `GuildStatus.GuildId`'si kayıtlı guild'lerden biri olanları CS map entrance gate'ine `WarpToAsync`.
- [ ] **Step 2:** PvP: CS map Definition'a `BattleZone` (Initialization'da) — Siege boyunca kill serbest. (Alternatif: CS context'i `AllowPlayerKilling=true` mini-game gibi kaydet.) Impl'de en az riskli yolu seç, doğrula.
- [ ] **Step 3:** Siege bitince (Ownership'e dönüş): CS map'teki oyuncuları `WarpToSafezoneAsync`.
- [ ] **Step 4:** Derle + smoke. **Step 5: Commit.**
> Not: kayıtlı-guild üye eşleştirmesi için context'te guild ADI tutuyoruz; warp'ta oyuncunun guild adını `GuildServer.GetGuildAsync(guildId).Name` ile çözüp kayıtlı listeyle karşılaştır (Guardsman handler'daki desen).
---
### Task 3: Taht NPC — ele geçirme
**Files:**
- Create: `src/GameLogic/CastleSiege/CastleSiegeThroneTalkPlugIn.cs`
- (Initialization) Taht NPC'sini CS map'e yerleştir (number ata).
**Interfaces:**
- Consumes: `IPlayerTalkToNpcPlugIn`, `CastleSiegeEventPlugIn.TryGetContext`, `context.CaptureThrone`.
- [ ] **Step 1:** `CastleSiegeThroneTalkPlugIn : IPlayerTalkToNpcPlugIn` (Guardsman deseni). Taht NPC number kontrolü → `eventArgs.HasBeenHandled=true` → Siege fazındaysa + oyuncu kayıtlı guild master/üyesiyse → `context.CaptureThrone(guildName)` + "Your guild captured the throne!" mesajı.
- [ ] **Step 2:** Taht NPC'sini CS map'e ekle (Initialization'da MonsterSpawn; number ata, örn. 277 "Castle Gate switch" S6 referansına bak).
- [ ] **Step 3:** Derle. **Step 4: Commit.**
---
### Task 4: Uçtan uca test + server image + admin akışı
- [ ] **Step 1:** Tüm CS testleri geçsin (`--filter CastleSiege`).
- [ ] **Step 2:** Image rebuild + container restart.
- [ ] **Step 3 (manuel/emülatör):** GM ile `/csstart` → Guardsman'de kayıt → `/csphase Siege` → oyuncu CS map'e warp olmalı, PvP açık, Taht'a konuş → "captured throne" → `/csphase Settlement``/csstatus` owner = guild. (Not: Taht ele geçirme akışı client↔server canlı doğrulanır.)
- [ ] **Step 4:** Gitea push.
---
## Sonraki increment'ler (P3 devamı)
- **Kapılar** (Destructible, HP, kırılınca terrain açılır — MiniGame terrain-change deseni).
- **Kristaller / Guardian Statue'lar** (Destructible; taht'a erişim için yıkılmalı).
- **Kontenjan/giriş kısıtı** (Siege'de sadece kayıtlı guild üyeleri map'e girebilir).
- **Tam S6 CS protokolü** (server→client CS state/time paketleri → client'ın native kuşatma UI'ı + **geri sayım** çalışır; client UI'ı hazır).
- **Contention** (tek ele geçirme yerine tutma süresi/skor ile kazanan).

View File

@@ -25,6 +25,12 @@ public class CastleSiegeConfiguration
/// <summary>Gets or sets how long the siege phase lasts.</summary> /// <summary>Gets or sets how long the siege phase lasts.</summary>
public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10); public TimeSpan SiegeDuration { get; set; } = TimeSpan.FromMinutes(10);
/// <summary>
/// Gets or sets the registration fee (in zen) a guild master must pay to register the guild
/// for the siege. 0 disables the fee.
/// </summary>
public int RegistrationFee { get; set; } = 100000;
/// <summary> /// <summary>
/// Returns true if <paramref name="now"/> falls within a 5-second window of any configured /// Returns true if <paramref name="now"/> falls within a 5-second window of any configured
/// registration-open time. /// registration-open time.

View File

@@ -12,6 +12,7 @@ public class CastleSiegeContext
{ {
private readonly List<string> _registeredGuilds = new(); private readonly List<string> _registeredGuilds = new();
private DateTime _phaseStartedUtc; private DateTime _phaseStartedUtc;
private string? _occupier;
/// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary> /// <summary>Initializes a new instance of the <see cref="CastleSiegeContext"/> class.</summary>
/// <param name="configuration">The cycle timing configuration.</param> /// <param name="configuration">The cycle timing configuration.</param>
@@ -36,6 +37,9 @@ public class CastleSiegeContext
/// <summary>Gets the guild names registered for the current cycle.</summary> /// <summary>Gets the guild names registered for the current cycle.</summary>
public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds; public IReadOnlyList<string> RegisteredGuilds => this._registeredGuilds;
/// <summary>Gets the guild currently holding the throne during the siege (P3), or null.</summary>
public string? OccupierGuildName => this._occupier;
/// <summary>Advances the state machine based on the current time.</summary> /// <summary>Advances the state machine based on the current time.</summary>
/// <param name="now">The current UTC time.</param> /// <param name="now">The current UTC time.</param>
public ValueTask TickAsync(DateTime now) public ValueTask TickAsync(DateTime now)
@@ -71,7 +75,13 @@ public class CastleSiegeContext
break; break;
case CastleSiegePhase.Settlement: case CastleSiegePhase.Settlement:
// P1: no battle -> no winner determination yet. Settle immediately back to ownership. // Winner = guild holding the throne at siege end (P3). If none captured, owner unchanged.
if (this._occupier is { } occupier)
{
this.SetOwner(occupier);
}
this._occupier = null;
return this.TransitionAsync(CastleSiegePhase.Ownership, now); return this.TransitionAsync(CastleSiegePhase.Ownership, now);
default: default:
break; break;
@@ -85,6 +95,7 @@ public class CastleSiegeContext
public ValueTask ForceStartRegistrationAsync(DateTime now) public ValueTask ForceStartRegistrationAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this._occupier = null;
return this.TransitionAsync(CastleSiegePhase.Registration, now); return this.TransitionAsync(CastleSiegePhase.Registration, now);
} }
@@ -99,6 +110,7 @@ public class CastleSiegeContext
public ValueTask ResetAsync(DateTime now) public ValueTask ResetAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this._occupier = null;
return this.TransitionAsync(CastleSiegePhase.Ownership, now); return this.TransitionAsync(CastleSiegePhase.Ownership, now);
} }
@@ -117,6 +129,16 @@ public class CastleSiegeContext
/// <param name="guildName">The owner guild name, or null to clear.</param> /// <param name="guildName">The owner guild name, or null to clear.</param>
public void SetOwner(string? guildName) => this.OwnerGuildName = guildName; public void SetOwner(string? guildName) => this.OwnerGuildName = guildName;
/// <summary>P3: a registered guild captures the throne during the siege. No-op outside the siege phase.</summary>
/// <param name="guildName">The capturing guild's name.</param>
public void CaptureThrone(string guildName)
{
if (this.Phase == CastleSiegePhase.Siege)
{
this._occupier = guildName;
}
}
/// <summary>Returns a human-readable status summary for admin display.</summary> /// <summary>Returns a human-readable status summary for admin display.</summary>
public string GetStatusText() public string GetStatusText()
=> $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, " => $"CS phase={this.Phase}, owner={this.OwnerGuildName ?? "(none)"}, "

View File

@@ -65,8 +65,23 @@ public class CastleSiegeGuardsmanTalkPlugIn : IPlayerTalkToNpcPlugIn
} }
} }
if (context.RegisteredGuilds.Contains(guildName))
{
await ShowAsync(player, $"Your guild '{guildName}' is already registered for the Castle Siege.").ConfigureAwait(false);
return;
}
var fee = context.Configuration.RegistrationFee;
if (fee > 0 && !player.TryRemoveMoney(fee))
{
await ShowAsync(player, $"You need {fee} zen to register your guild for the Castle Siege.").ConfigureAwait(false);
return;
}
context.RegisterGuild(guildName); context.RegisterGuild(guildName);
await ShowAsync(player, $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false); await ShowAsync(player, fee > 0
? $"Your guild '{guildName}' is registered for the Castle Siege. ({fee} zen paid)"
: $"Your guild '{guildName}' is registered for the Castle Siege.").ConfigureAwait(false);
} }
private static ValueTask ShowAsync(Player player, string text) private static ValueTask ShowAsync(Player player, string text)

View File

@@ -67,6 +67,30 @@ public class CastleSiegeContextTest
Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers")); Assert.That(ctx.RegisteredGuilds, Does.Contain("Attackers"));
} }
/// <summary>Tests that the throne-holding guild at siege end becomes the owner on settlement (P3).</summary>
[Test]
public async Task ThroneCaptureDuringSiegeBecomesOwnerOnSettlementAsync()
{
var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.CaptureThrone("Attackers");
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership (owner assigned)
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
}
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary>
[Test]
public void CaptureThroneOutsideSiegeIsNoOp()
{
var ctx = new CastleSiegeContext(Config());
ctx.CaptureThrone("Attackers");
Assert.That(ctx.OccupierGuildName, Is.Null);
}
private static CastleSiegeConfiguration Config() => new() private static CastleSiegeConfiguration Config() => new()
{ {
RegistrationDuration = TimeSpan.FromMinutes(5), RegistrationDuration = TimeSpan.FromMinutes(5),