feat(hs): sequential statue spawn + guard mobs + win detection

Adds HeykelSavasiContext runtime logic for Task 4.2: per-team statue
break progress/winner tracking (extracted into a pure, testable
StatueProgressState), OnDestructibleDied wiring against the base
MiniGameContext's existing Destructible->Died auto-subscription, and
SpawnStatueAsync which spawns the next statue (561) + 4 guards (580)
via the map initializer using placeholder line-of-statues coordinates.
OnGameStartAsync now spawns each team's first statue after the warp.
This commit is contained in:
Acentech Dev
2026-07-21 00:07:00 +03:00
parent 32e05e2bc1
commit 712773c332
2 changed files with 395 additions and 0 deletions

View File

@@ -10,7 +10,9 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
using MUnique.OpenMU.Pathfinding;
/// <summary>
/// The context of the Heykel Savasi (statue war) event.
@@ -50,8 +52,81 @@ public class HeykelSavasiContext : MiniGameContext
/// </summary>
private const byte BlueBaseAnchorY1 = 220;
/// <summary>
/// The number of statues (per team) which need to be broken by the opposing team to win the event.
/// </summary>
public const int StatuesToBreak = 7;
/// <summary>
/// The <see cref="MUnique.OpenMU.DataModel.Configuration.MonsterDefinition.Number"/> of the destructible
/// statue NPC (see <c>HeykelSavasiMap.CreateMonsters</c>).
/// </summary>
private const short StatueMonsterNumber = 561;
/// <summary>
/// The <see cref="MUnique.OpenMU.DataModel.Configuration.MonsterDefinition.Number"/> of the guard NPC
/// which spawns around each statue (see <c>HeykelSavasiMap.CreateMonsters</c>).
/// </summary>
private const short GuardMonsterNumber = 580;
/// <summary>
/// The (dx, dy) offsets, relative to a statue's position, at which its 4 guards spawn.
/// </summary>
private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, -2), (0, 2) };
/// <summary>
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
/// approximate straight line from mid-map toward the red base (20, 20). Index 0 is the outermost statue
/// (attacked first by the blue team), index 6 is the final statue, closest to the red base (win condition
/// for blue once broken).
/// </summary>
private static readonly Point[] RedStatuePositions =
{
new(100, 100),
new(88, 88),
new(77, 77),
new(65, 65),
new(53, 53),
new(42, 42),
new(30, 30),
};
/// <summary>
/// PLACEHOLDER coordinates: the Heykel Savasi map terrain is not final yet, so these positions are an
/// approximate straight line from mid-map toward the blue base (220, 220). Index 0 is the outermost statue
/// (attacked first by the red team), index 6 is the final statue, closest to the blue base (win condition
/// for red once broken).
/// </summary>
private static readonly Point[] BlueStatuePositions =
{
new(140, 140),
new(152, 152),
new(163, 163),
new(175, 175),
new(187, 187),
new(198, 198),
new(210, 210),
};
private readonly IGameContext _gameContext;
private readonly IMapInitializer _mapInitializer;
private readonly ConcurrentDictionary<Player, HeykelSavasiTeam> _teams = new();
/// <summary>
/// Maps a currently-alive, spawned statue NPC to the team it defends and its index (0..6) in that
/// team's statue line. Populated by <see cref="SpawnStatueAsync"/>, consumed (and removed) by
/// <see cref="OnDestructibleDied"/> once the statue is destroyed.
/// </summary>
private readonly ConcurrentDictionary<AttackableNpcBase, (HeykelSavasiTeam Defender, int Index)> _statues = new();
/// <summary>
/// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be
/// unit-tested without constructing a full <see cref="HeykelSavasiContext"/> (whose constructor needs a
/// live <see cref="IGameContext"/> and starts a background game loop via <c>Task.Run</c>).
/// </summary>
private readonly StatueProgressState _progress = new();
/// <summary>
/// Synchronizes the check-then-act sequence of <see cref="TryJoinTeamAsync"/>, so that concurrent
/// join attempts cannot both pass the balance check before either of them reserves a slot.
@@ -68,6 +143,8 @@ public class HeykelSavasiContext : MiniGameContext
public HeykelSavasiContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
this._gameContext = gameContext;
this._mapInitializer = mapInitializer;
}
/// <summary>
@@ -132,6 +209,19 @@ public class HeykelSavasiContext : MiniGameContext
/// <returns>All players assigned to <paramref name="team"/>.</returns>
public IEnumerable<Player> PlayersOf(HeykelSavasiTeam team) => this._teams.Where(kv => kv.Value == team).Select(kv => kv.Key);
/// <summary>
/// Gets the number of the given team's own statues which have been broken by the opposing team so far.
/// </summary>
/// <param name="attacker">The attacking team.</param>
/// <returns>The number of the opponent's statues <paramref name="attacker"/> has destroyed (0..<see cref="StatuesToBreak"/>).</returns>
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.GetProgress(attacker);
/// <summary>
/// Gets the team which has won the event by breaking all of the opponent's statues, if any.
/// </summary>
/// <returns>The winning team, or <see cref="HeykelSavasiTeam.None"/> if the event hasn't been decided yet.</returns>
public HeykelSavasiTeam GetWinner() => this._progress.GetWinner();
/// <summary>
/// Atomically attempts to join the given player to the given team, reserving a slot under
/// <see cref="_teamLock"/> to fix a check-then-act race between the balance check and the team
@@ -207,6 +297,67 @@ public class HeykelSavasiContext : MiniGameContext
{
await player.WarpToAsync(this.GetTeamSpawnGate(this.GetTeam(player))).ConfigureAwait(false);
}
// Spawn the first (outermost) statue of each team's line; the opposing team attacks it.
await this.SpawnStatueAsync(HeykelSavasiTeam.Red, 0).ConfigureAwait(false);
await this.SpawnStatueAsync(HeykelSavasiTeam.Blue, 0).ConfigureAwait(false);
}
/// <inheritdoc />
/// <remarks>
/// Note: <see cref="MiniGameContext.OnObjectAddedToMapAsync"/> already subscribes every
/// <see cref="Destructible"/> added to <see cref="MiniGameContext.Map"/> to
/// <see cref="OnDestructibleDied"/> (see MiniGameContext.cs), so statues spawned via
/// <see cref="SpawnStatueAsync"/> are routed here automatically; this override must not
/// subscribe <c>Died</c> again.
/// </remarks>
protected override void OnDestructibleDied(object? sender, DeathInformation e)
{
base.OnDestructibleDied(sender, e);
if (sender is not AttackableNpcBase npc || !this._statues.TryRemove(npc, out var info))
{
return;
}
var attacker = Opponent(info.Defender);
this.HandleStatueDestroyed(info.Defender, info.Index, attacker, spawnNext: true);
// Fire-and-forget the async side effects (buff application, spawning the next statue, or
// finishing the event); the death event handler itself must stay synchronous.
_ = this.OnStatueDestroyedSideEffectsAsync(info.Defender, info.Index, attacker);
}
/// <summary>
/// Registers, for tests only, that <paramref name="attacker"/> destroyed <paramref name="defender"/>'s
/// statue at <paramref name="index"/>, without triggering any spawn/warp/buff side effects.
/// </summary>
/// <param name="defender">The team whose statue was destroyed.</param>
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
/// <param name="attacker">The team which destroyed the statue.</param>
internal void RegisterStatueDestroyedForTest(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
=> this.HandleStatueDestroyed(defender, index, attacker, spawnNext: false);
/// <summary>
/// Gets the fixed sequence of 7 statue positions defending <paramref name="team"/>'s own line (attacked
/// by the opposing team). Index 0 is the outermost/first statue, index 6 is the final one.
/// </summary>
/// <param name="team">The defending team.</param>
/// <returns>The 7 statue positions of <paramref name="team"/>'s line, or an empty array for <see cref="HeykelSavasiTeam.None"/>.</returns>
/// <remarks>
/// This is deliberately kept in the GameLogic project (rather than
/// <c>Persistence.Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs</c>, as originally sketched in
/// the task brief) because <c>MUnique.OpenMU.GameLogic</c> does not - and must not - reference
/// <c>MUnique.OpenMU.Persistence.Initialization</c>; the dependency only goes the other way.
/// </remarks>
internal static Point[] StatuePositions(HeykelSavasiTeam team)
{
return team switch
{
HeykelSavasiTeam.Red => RedStatuePositions,
HeykelSavasiTeam.Blue => BlueStatuePositions,
_ => Array.Empty<Point>(),
};
}
/// <summary>
@@ -234,4 +385,201 @@ public class HeykelSavasiContext : MiniGameContext
return Math.Abs(redCount - blueCount) <= MaxTeamDifference;
}
/// <summary>
/// Gets the opposing team of <paramref name="team"/>. <see cref="HeykelSavasiTeam.None"/> maps to itself.
/// </summary>
/// <param name="team">The team.</param>
/// <returns>The opposing team.</returns>
private static HeykelSavasiTeam Opponent(HeykelSavasiTeam team) => team switch
{
HeykelSavasiTeam.Red => HeykelSavasiTeam.Blue,
HeykelSavasiTeam.Blue => HeykelSavasiTeam.Red,
_ => HeykelSavasiTeam.None,
};
/// <summary>
/// Pure state transition shared by the runtime death handler (<see cref="OnDestructibleDied"/>) and the
/// test-only <see cref="RegisterStatueDestroyedForTest"/>: records the attacker's progress and, once
/// <see cref="StatuesToBreak"/> statues have fallen, the winner. Spawning the next statue and applying
/// buffs are runtime-only side effects, triggered separately by the caller when <paramref name="spawnNext"/>
/// is <c>true</c> (see <see cref="OnStatueDestroyedSideEffectsAsync"/>).
/// </summary>
/// <param name="defender">The team whose statue was destroyed.</param>
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
/// <param name="attacker">The team which destroyed the statue.</param>
/// <param name="spawnNext">
/// Unused by this pure method; documents, at call sites, whether the caller intends to trigger the
/// runtime spawn-next/buff/finish side effects afterwards (<see langword="true"/> from
/// <see cref="OnDestructibleDied"/>) or not (<see langword="false"/> from tests).
/// </param>
private void HandleStatueDestroyed(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker, bool spawnNext)
{
_ = defender;
_ = spawnNext;
this._progress.RegisterDestroyed(index, attacker);
}
/// <summary>
/// Handles the async side effects of a statue's destruction: applying the destruction buff to the
/// attacking team, finishing the event if that statue was the winning one, or spawning the defender's
/// next statue (plus guards) otherwise.
/// </summary>
/// <param name="defender">The team whose statue was destroyed.</param>
/// <param name="index">The index (0..6) of the destroyed statue in <paramref name="defender"/>'s line.</param>
/// <param name="attacker">The team which destroyed the statue.</param>
private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
{
try
{
// Task 5.1: apply buff here
if (this.GetWinner() == attacker)
{
this.FinishEvent();
return;
}
await this.SpawnStatueAsync(defender, index + 1).ConfigureAwait(false);
}
catch (Exception ex)
{
this.Logger.LogError(ex, "{context}: Error handling statue-destroyed side effects for defender {defender}, index {index}.", this, defender, index);
}
}
/// <summary>
/// Spawns <paramref name="defender"/>'s statue at <paramref name="index"/> in its line, plus 4 guard
/// mobs around it, using the same runtime-spawn API the map initializer itself uses
/// (<see cref="IMapInitializer.InitializeSpawnAsync"/>; see also Castle Siege's
/// <c>CastleSiegeEventPlugIn.SpawnCastleDefensesAsync</c> for the reference pattern). The spawned statue
/// is registered in <see cref="_statues"/> so <see cref="OnDestructibleDied"/> can attribute its death to
/// the correct defender/index; its <c>Died</c> event does not need to be subscribed here because
/// <see cref="MiniGameContext.OnObjectAddedToMapAsync"/> already does so for every
/// <see cref="Destructible"/> added to <see cref="MiniGameContext.Map"/>.
/// </summary>
/// <param name="defender">The team whose statue line is being extended.</param>
/// <param name="index">The index (0..6) of the statue to spawn in <paramref name="defender"/>'s line.</param>
private async ValueTask SpawnStatueAsync(HeykelSavasiTeam defender, int index)
{
try
{
var positions = StatuePositions(defender);
if (index < 0 || index >= positions.Length)
{
this.Logger.LogWarning("{context}: Statue index {index} is out of range for defender {defender}.", this, index, defender);
return;
}
var pos = positions[index];
var statueDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == StatueMonsterNumber);
if (statueDefinition is null)
{
this.Logger.LogWarning("{context}: Statue monster definition {number} not found.", this, StatueMonsterNumber);
return;
}
// Distinct spawn-index ranges per (defender, index), so repeated calls across the event
// never collide: e.g. Red index 0 -> 1000..1004, Blue index 3 -> 2030..2034.
var spawnIndexBase = ((int)defender * 1000) + (index * 10);
var statueSpawnArea = new MonsterSpawnArea
{
MonsterDefinition = statueDefinition,
Quantity = 1,
X1 = pos.X,
X2 = pos.X,
Y1 = pos.Y,
Y2 = pos.Y,
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
var statueNpc = await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase, this.Map, statueSpawnArea, this).ConfigureAwait(false);
if (statueNpc is AttackableNpcBase attackable)
{
this._statues[attackable] = (defender, index);
}
var guardDefinition = this._gameContext.Configuration.Monsters.FirstOrDefault(m => m.Number == GuardMonsterNumber);
if (guardDefinition is null)
{
this.Logger.LogWarning("{context}: Guard monster definition {number} not found.", this, GuardMonsterNumber);
return;
}
for (var i = 0; i < GuardOffsets.Length; i++)
{
var (dx, dy) = GuardOffsets[i];
var guardSpawnArea = new MonsterSpawnArea
{
MonsterDefinition = guardDefinition,
Quantity = 1,
X1 = (byte)(pos.X + dx),
X2 = (byte)(pos.X + dx),
Y1 = (byte)(pos.Y + dy),
Y2 = (byte)(pos.Y + dy),
Direction = Direction.South,
SpawnTrigger = SpawnTrigger.OnceAtEventStart,
};
await this._mapInitializer.InitializeSpawnAsync(spawnIndexBase + 1 + i, this.Map, guardSpawnArea, this).ConfigureAwait(false);
}
}
catch (Exception ex)
{
this.Logger.LogError(ex, "{context}: Error spawning statue for defender {defender}, index {index}.", this, defender, index);
}
}
/// <summary>
/// Pure, dependency-free tracker for statue-break progress and win detection. Extracted out of
/// <see cref="HeykelSavasiContext"/> so it can be unit-tested directly, without constructing a full
/// context (whose constructor requires a live <see cref="IGameContext"/>/<see cref="IMapInitializer"/>
/// and starts a background game loop via <c>Task.Run</c> - impractical in a fast unit test; see also
/// <see cref="IsJoinAllowed"/>, extracted for the same reason). Kept <c>internal</c> (rather than
/// <c>private</c>) specifically so <c>MUnique.OpenMU.Tests</c> can drive it directly.
/// </summary>
internal sealed class StatueProgressState
{
private readonly ConcurrentDictionary<HeykelSavasiTeam, int> _progress = new()
{
[HeykelSavasiTeam.Red] = 0,
[HeykelSavasiTeam.Blue] = 0,
};
private HeykelSavasiTeam _winner = HeykelSavasiTeam.None;
/// <summary>
/// Gets the number of the opponent's statues <paramref name="attacker"/> has destroyed so far.
/// </summary>
/// <param name="attacker">The attacking team.</param>
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
/// <summary>
/// Gets the winning team, or <see cref="HeykelSavasiTeam.None"/> if undecided.
/// </summary>
public HeykelSavasiTeam GetWinner() => this._winner;
/// <summary>
/// Registers that <paramref name="attacker"/> destroyed the statue at <paramref name="index"/> in
/// the opponent's line. Once a winner is set, further registrations are ignored.
/// </summary>
/// <param name="index">The index (0..6) of the destroyed statue.</param>
/// <param name="attacker">The team which destroyed the statue.</param>
public void RegisterDestroyed(int index, HeykelSavasiTeam attacker)
{
if (this._winner != HeykelSavasiTeam.None)
{
return;
}
this._progress[attacker] = index + 1;
if (index + 1 >= StatuesToBreak)
{
this._winner = attacker;
}
}
}
}