feat(CS-P3): full siege objective chain - guardian statues (Destructible spawn) + dual Crown Switch hold + throne capture gating

This commit is contained in:
Acentech Dev
2026-07-15 01:52:19 +03:00
parent 5549c2bec7
commit c50af5e5c2
7 changed files with 234 additions and 15 deletions

View File

@@ -167,6 +167,7 @@ docker push <hub>/adamu-openmu:<tag>
| `GameLogic/GameMap.cs` | 0b | remote-NPC: `GetNpcByNumber` helper (additive metod) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli; test: `GameMapTest.GetNpcByNumberFindsSpawnedNpcAsync` | | `GameLogic/GameMap.cs` | 0b | remote-NPC: `GetNpcByNumber` helper (additive metod) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli; test: `GameMapTest.GetNpcByNumberFindsSpawnedNpcAsync` |
| `GameServer/MessageHandler/TalkNpcHandlerPlugInBase.cs` | 0b | remote-NPC: `0x8000` marker dalı (tek gerçek core-logic dokunuşu) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli | | `GameServer/MessageHandler/TalkNpcHandlerPlugInBase.cs` | 0b | remote-NPC: `0x8000` marker dalı (tek gerçek core-logic dokunuşu) | ✅ Uygulandı, `// ADAMU-CUSTOM` işaretli |
| `GameLogic/Player.cs` | P3 | CS PvP: Siege fazında Valley of Loren'de (map 30) kill izni (`IsCastleSiegeBattleActive`) | ✅ Uygulandı, `// ADAMU-CUSTOM` | | `GameLogic/Player.cs` | P3 | CS PvP: Siege fazında Valley of Loren'de (map 30) kill izni (`IsCastleSiegeBattleActive`) | ✅ Uygulandı, `// ADAMU-CUSTOM` |
| `GameLogic/GameContext.cs` | P3 | `MapInitializer` property expose (guardian heykel Destructible runtime spawn) | ✅ Uygulandı, `// ADAMU-CUSTOM` |
| DataModel + Initialization (CS entity'leri) | P4 | CS persistence (sahiplik/vergi) | ⏳ Planlanacak (EF migration gerekir) | | DataModel + Initialization (CS entity'leri) | P4 | CS persistence (sahiplik/vergi) | ⏳ Planlanacak (EF migration gerekir) |
| *(fazlar ilerledikçe eklenecek)* | | | | | *(fazlar ilerledikçe eklenecek)* | | | |

View File

@@ -11,8 +11,10 @@ namespace MUnique.OpenMU.GameLogic.CastleSiege;
public class CastleSiegeContext public class CastleSiegeContext
{ {
private readonly List<string> _registeredGuilds = new(); private readonly List<string> _registeredGuilds = new();
private readonly Dictionary<short, string> _switchHolders = new();
private DateTime _phaseStartedUtc; private DateTime _phaseStartedUtc;
private string? _occupier; private string? _occupier;
private int _statuesRemaining;
/// <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>
@@ -95,6 +97,8 @@ public class CastleSiegeContext
public ValueTask ForceStartRegistrationAsync(DateTime now) public ValueTask ForceStartRegistrationAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this._switchHolders.Clear();
this._statuesRemaining = 0;
this._occupier = null; this._occupier = null;
return this.TransitionAsync(CastleSiegePhase.Registration, now); return this.TransitionAsync(CastleSiegePhase.Registration, now);
} }
@@ -110,6 +114,8 @@ public class CastleSiegeContext
public ValueTask ResetAsync(DateTime now) public ValueTask ResetAsync(DateTime now)
{ {
this._registeredGuilds.Clear(); this._registeredGuilds.Clear();
this._switchHolders.Clear();
this._statuesRemaining = 0;
this._occupier = null; this._occupier = null;
return this.TransitionAsync(CastleSiegePhase.Ownership, now); return this.TransitionAsync(CastleSiegePhase.Ownership, now);
} }
@@ -129,16 +135,65 @@ 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> /// <summary>Gets the number of guardian statues still standing (must be 0 before the throne can be taken).</summary>
/// <param name="guildName">The capturing guild's name.</param> public int StatuesRemaining => this._statuesRemaining;
public void CaptureThrone(string guildName)
/// <summary>Sets how many guardian statues defend the castle this siege (called when they are spawned).</summary>
/// <param name="count">The statue count.</param>
public void SetStatueCount(int count) => this._statuesRemaining = count > 0 ? count : 0;
/// <summary>Notifies that a guardian statue was destroyed. Lowers the remaining count (min 0).</summary>
public void NotifyStatueDestroyed()
{
if (this._statuesRemaining > 0)
{
this._statuesRemaining--;
}
}
/// <summary>
/// P3: a registered guild member activates one of the two Crown Switches (217/218) during the siege.
/// The throne can only be taken by a guild holding BOTH switches. No-op outside the siege phase.
/// </summary>
/// <param name="switchNumber">The Crown Switch NPC number (217 or 218).</param>
/// <param name="guildName">The activating guild's name.</param>
public void HoldSwitch(short switchNumber, string guildName)
{ {
if (this.Phase == CastleSiegePhase.Siege) if (this.Phase == CastleSiegePhase.Siege)
{ {
this._occupier = guildName; this._switchHolders[switchNumber] = guildName;
} }
} }
/// <summary>
/// P3: attempts to capture the throne for a guild. Succeeds only during the siege when all guardian
/// statues are destroyed and the guild holds both Crown Switches.
/// </summary>
/// <param name="guildName">The capturing guild's name.</param>
/// <returns>A tuple of success and a human-readable reason/result message.</returns>
public (bool Success, string Reason) TryCaptureThrone(string guildName)
{
if (this.Phase != CastleSiegePhase.Siege)
{
return (false, "The siege is not running.");
}
if (this._statuesRemaining > 0)
{
return (false, $"Destroy the guardian statues first ({this._statuesRemaining} remaining).");
}
var holdsBoth = this._switchHolders.TryGetValue(217, out var s217) && s217 == guildName
&& this._switchHolders.TryGetValue(218, out var s218) && s218 == guildName;
if (!holdsBoth)
{
return (false, "Your guild must hold both Crown Switches (217 and 218) at once.");
}
this._occupier = guildName;
return (true, "throne captured");
}
/// <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

@@ -0,0 +1,77 @@
// <copyright file="CastleSiegeCrownTalkPlugIn.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;
using System.Linq;
using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlugIns;
using MUnique.OpenMU.GameLogic.PlugIns.PeriodicTasks;
using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns;
/// <summary>
/// Handles talking to the Castle Siege throne NPCs — Crown (216) and Sinior (223) — on Valley of Loren.
/// A registered guild takes the throne only when it has destroyed all guardian statues AND holds both
/// Crown Switches (217/218). The guild on the throne when the siege ends becomes the castle owner.
/// </summary>
[Guid("CA5710A0-7A1B-4C2D-8E3F-000000000216")]
[PlugIn]
[Display(Name = "Castle Siege Crown/Throne", Description = "Captures the throne (Crown 216 / Sinior 223) once statues are down and both switches held.")]
public class CastleSiegeCrownTalkPlugIn : IPlayerTalkToNpcPlugIn
{
private static readonly short[] ThroneNpcNumbers = { 216, 223 };
/// <inheritdoc />
public async ValueTask PlayerTalksToNpcAsync(Player player, NonPlayerCharacter npc, NpcTalkEventArgs eventArgs)
{
if (!ThroneNpcNumbers.Contains(npc.Definition.Number))
{
return;
}
eventArgs.HasBeenHandled = true;
var context = CastleSiegeEventPlugIn.TryGetContext(player.GameContext);
if (context is null)
{
await ShowAsync(player, "Castle Siege is not active on this server.").ConfigureAwait(false);
return;
}
if (player.GuildStatus is not { } guildStatus)
{
await ShowAsync(player, "Only members of a registered guild can take the throne.").ConfigureAwait(false);
return;
}
var guildName = guildStatus.GuildId.ToString();
if (player.GameContext is IGameServerContext serverContext)
{
var guild = await serverContext.GuildServer.GetGuildAsync(guildStatus.GuildId).ConfigureAwait(false);
if (guild?.Name is { Length: > 0 } name)
{
guildName = name;
}
}
if (!context.RegisteredGuilds.Contains(guildName))
{
await ShowAsync(player, "Your guild is not registered for this Castle Siege.").ConfigureAwait(false);
return;
}
var (success, reason) = context.TryCaptureThrone(guildName);
await ShowAsync(
player,
success
? $"Your guild '{guildName}' has CAPTURED THE THRONE! Hold it until the siege ends to win the castle!"
: reason).ConfigureAwait(false);
}
private static ValueTask ShowAsync(Player player, string text)
=> player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(text, MessageType.BlueNormal));
}

View File

@@ -70,8 +70,8 @@ public class CastleSiegeThroneTalkPlugIn : IPlayerTalkToNpcPlugIn
return; return;
} }
context.CaptureThrone(guildName); context.HoldSwitch(npc.Definition.Number, guildName);
await ShowAsync(player, $"Your guild '{guildName}' has captured the throne! Hold it until the siege ends to win the castle.").ConfigureAwait(false); await ShowAsync(player, $"Your guild '{guildName}' is holding this Crown Switch. Hold BOTH switches (217 and 218) and destroy the guardian statues, then take the throne!").ConfigureAwait(false);
} }
private static ValueTask ShowAsync(Player player, string text) private static ValueTask ShowAsync(Player player, string text)

View File

@@ -116,6 +116,10 @@ public class GameContext : AsyncDisposable, IGameContext
/// <inheritdoc /> /// <inheritdoc />
public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate; public virtual float MasterExperienceRate => this.Configuration.MasterExperienceRate;
// ADAMU-CUSTOM: expose the map initializer so the Castle Siege can spawn guardian statues (Destructibles) at runtime.
/// <summary>Gets the map initializer (used to spawn NPCs/destructibles at runtime, e.g. Castle Siege statues).</summary>
public IMapInitializer MapInitializer => this._mapInitializer;
/// <inheritdoc /> /// <inheritdoc />
public virtual bool PvpEnabled { get; } public virtual bool PvpEnabled { get; }

View File

@@ -8,6 +8,7 @@ using System.Collections.Concurrent;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using MUnique.OpenMU.GameLogic.CastleSiege; using MUnique.OpenMU.GameLogic.CastleSiege;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.Views; using MUnique.OpenMU.GameLogic.Views;
using MUnique.OpenMU.Interfaces; using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.PlugIns; using MUnique.OpenMU.PlugIns;
@@ -23,6 +24,8 @@ using MUnique.OpenMU.PlugIns;
public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustomConfiguration<CastleSiegeConfiguration>, ISupportDefaultCustomConfiguration
{ {
private const ushort ValleyOfLorenMapNumber = 30; private const ushort ValleyOfLorenMapNumber = 30;
private const short GuardianStatueNumber = 132; // reuse the "Statue of Saint" destructible definition
private static readonly (byte X, byte Y)[] StatuePositions = { (170, 206), (182, 206) };
private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new(); private static readonly ConcurrentDictionary<IGameContext, CastleSiegeContext> Contexts = new();
@@ -76,7 +79,8 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false); await AnnounceAsync(gameContext, "Castle Siege registration is now open! Guild masters, register at the Guardsman in the Valley of Loren.").ConfigureAwait(false);
break; break;
case CastleSiegePhase.Siege: case CastleSiegePhase.Siege:
await AnnounceAsync(gameContext, "The Castle Siege has begun! Fight your way to the throne and capture it to win the castle!").ConfigureAwait(false); await AnnounceAsync(gameContext, "The Castle Siege has begun! Destroy the guardian statues, hold both Crown Switches, then take the throne!").ConfigureAwait(false);
await SpawnGuardianStatuesAsync(gameContext, context).ConfigureAwait(false);
await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false); await WarpRegisteredMembersToSiegeAsync(gameContext, context).ConfigureAwait(false);
break; break;
case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner: case CastleSiegePhase.Ownership when context.OwnerGuildName is { } owner:
@@ -97,6 +101,58 @@ public sealed class CastleSiegeEventPlugIn : IPeriodicTaskPlugIn, ISupportCustom
=> gameContext.ForEachPlayerAsync(player => => gameContext.ForEachPlayerAsync(player =>
player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask()); player.InvokeViewPlugInAsync<IShowMessagePlugIn>(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask());
private static async Task SpawnGuardianStatuesAsync(IGameContext gameContext, CastleSiegeContext context)
{
try
{
if (gameContext is not GameContext concrete)
{
context.SetStatueCount(0);
return;
}
var map = await gameContext.GetMapAsync(ValleyOfLorenMapNumber).ConfigureAwait(false);
var statueDef = gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardianStatueNumber);
if (map is null || statueDef is null)
{
context.SetStatueCount(0);
return;
}
var spawned = 0;
for (var i = 0; i < StatuePositions.Length; i++)
{
var pos = StatuePositions[i];
var spawnArea = new MonsterSpawnArea
{
MonsterDefinition = statueDef,
Quantity = 1,
X1 = pos.X,
X2 = pos.X,
Y1 = pos.Y,
Y2 = pos.Y,
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
var npc = await concrete.MapInitializer.InitializeSpawnAsync(1000 + i, map, spawnArea).ConfigureAwait(false);
if (npc is AttackableNpcBase attackable)
{
attackable.Died += (_, _) => context.NotifyStatueDestroyed();
spawned++;
}
}
context.SetStatueCount(spawned);
}
catch (Exception ex)
{
gameContext.LoggerFactory.CreateLogger<CastleSiegeEventPlugIn>()
.LogError(ex, "Castle Siege: error while spawning guardian statues.");
context.SetStatueCount(0);
}
}
private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context) private static async Task WarpRegisteredMembersToSiegeAsync(IGameContext gameContext, CastleSiegeContext context)
{ {
try try

View File

@@ -67,27 +67,53 @@ 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> /// <summary>Tests the full siege objective chain: destroy statues, hold both switches, then capture the throne.</summary>
[Test] [Test]
public async Task ThroneCaptureDuringSiegeBecomesOwnerOnSettlementAsync() public async Task FullSiegeObjectiveChainToOwnershipAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
await ctx.ForceStartRegistrationAsync(T0); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0); await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.CaptureThrone("Attackers"); ctx.SetStatueCount(2);
// Throne blocked until statues destroyed + both switches held.
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False);
ctx.NotifyStatueDestroyed();
ctx.NotifyStatueDestroyed();
ctx.HoldSwitch(217, "Attackers");
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.False); // only one switch held
ctx.HoldSwitch(218, "Attackers");
Assert.That(ctx.TryCaptureThrone("Attackers").Success, Is.True); // both switches + statues down
Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OccupierGuildName, Is.EqualTo("Attackers"));
await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement await ctx.TickAsync(T0.AddMinutes(20)); // Siege -> Settlement
await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership (owner assigned) await ctx.TickAsync(T0.AddMinutes(20)); // Settlement -> Ownership
Assert.That(ctx.Phase, Is.EqualTo(CastleSiegePhase.Ownership));
Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers")); Assert.That(ctx.OwnerGuildName, Is.EqualTo("Attackers"));
} }
/// <summary>Tests that capturing the throne outside the siege phase is a no-op.</summary> /// <summary>Tests that a rival guild holding only one switch cannot steal the throne.</summary>
[Test] [Test]
public void CaptureThroneOutsideSiegeIsNoOp() public async Task ThroneRequiresBothSwitchesBySameGuildAsync()
{ {
var ctx = new CastleSiegeContext(Config()); var ctx = new CastleSiegeContext(Config());
ctx.CaptureThrone("Attackers"); await ctx.ForceStartRegistrationAsync(T0);
await ctx.ForcePhaseAsync(CastleSiegePhase.Siege, T0);
ctx.SetStatueCount(0);
ctx.HoldSwitch(217, "A");
ctx.HoldSwitch(218, "B");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False);
Assert.That(ctx.TryCaptureThrone("B").Success, Is.False);
}
/// <summary>Tests that objective actions outside the siege phase are no-ops.</summary>
[Test]
public void ObjectiveActionsOutsideSiegeAreNoOp()
{
var ctx = new CastleSiegeContext(Config());
ctx.HoldSwitch(217, "A");
Assert.That(ctx.TryCaptureThrone("A").Success, Is.False);
Assert.That(ctx.OccupierGuildName, Is.Null); Assert.That(ctx.OccupierGuildName, Is.Null);
} }