//
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
//
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.NPC;
using MUnique.OpenMU.GameLogic.PlayerActions.MiniGames;
using MUnique.OpenMU.Pathfinding;
///
/// The context of the Heykel Savasi (statue war) event.
///
///
/// This is the foundation class for the event: it tracks which team every participating player
/// belongs to, and enforces a team-balance rule when players attempt to join a team.
///
public class HeykelSavasiContext : MiniGameContext
{
///
/// The maximum allowed difference between the red and blue team sizes after a join.
///
private const int MaxTeamDifference = 2;
///
/// The X1/Y1 anchor of the red team's base spawn gate.
/// Must match Gates.cs targetGates 600 (RedBase).
///
private const byte RedBaseAnchorX1 = 20;
///
/// The X1/Y1 anchor of the red team's base spawn gate.
/// Must match Gates.cs targetGates 600 (RedBase).
///
private const byte RedBaseAnchorY1 = 20;
///
/// The X1/Y1 anchor of the blue team's base spawn gate.
/// Must match Gates.cs targetGates 601 (BlueBase).
///
private const byte BlueBaseAnchorX1 = 220;
///
/// The X1/Y1 anchor of the blue team's base spawn gate.
/// Must match Gates.cs targetGates 601 (BlueBase).
///
private const byte BlueBaseAnchorY1 = 220;
///
/// The number of statues (per team) which need to be broken by the opposing team to win the event.
///
public const int StatuesToBreak = 7;
///
/// The of the destructible
/// statue NPC (see HeykelSavasiMap.CreateMonsters).
///
private const short StatueMonsterNumber = 561;
///
/// The of the guard NPC
/// which spawns around each statue (see HeykelSavasiMap.CreateMonsters).
///
private const short GuardMonsterNumber = 580;
///
/// The (dx, dy) offsets, relative to a statue's position, at which its 4 guards spawn.
///
private static readonly (int Dx, int Dy)[] GuardOffsets = { (-2, 0), (2, 0), (0, -2), (0, 2) };
///
/// Maps a destroyed statue's index (0..5) to the
/// of the class buff granted to the whole attacking team. All of these effects already exist in
/// configuration (no custom MagicEffectDefinitions were added): 1=GreaterDamage, 2=GreaterDefense,
/// 4=SoulBarrier, 5=CriticalDamageIncrease (DL), 0x52=WizEnhance (SM), 129=IgnoreDefense (RF).
/// Index 6 (the 7th/final statue) intentionally has no entry - breaking it is the win condition, not a
/// buff trigger; see the bounds check in .
///
private static readonly short[] StatueBuffEffectNumbers = { 1, 2, 4, 5, 0x52, 129 };
///
/// The duration of the class buff granted on a statue break, chosen to comfortably outlast a single
/// match (matches run longer than 5 minutes).
///
private static readonly TimeSpan BuffDuration = TimeSpan.FromMinutes(6);
///
/// 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).
///
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),
};
///
/// 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).
///
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 _teams = new();
///
/// 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 , consumed (and removed) by
/// once the statue is destroyed.
///
private readonly ConcurrentDictionary _statues = new();
///
/// Pure statue-break progress/winner state, extracted into its own dependency-free type so it can be
/// unit-tested without constructing a full (whose constructor needs a
/// live and starts a background game loop via Task.Run).
///
private readonly StatueProgressState _progress = new();
///
/// Synchronizes the check-then-act sequence of , so that concurrent
/// join attempts cannot both pass the balance check before either of them reserves a slot.
///
private readonly object _teamLock = new();
///
/// Initializes a new instance of the class.
///
/// The key of this context.
/// The definition of the mini game.
/// The game context, to which this game belongs.
/// The map initializer, which is used when the event starts.
public HeykelSavasiContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
this._gameContext = gameContext;
this._mapInitializer = mapInitializer;
}
///
/// Gets a value indicating whether players are allowed to kill each other.
///
///
/// This does not prevent friendly fire (team members killing each other); that restriction
/// is added separately via a hook in a later phase.
///
public override bool AllowPlayerKilling => this.State == MiniGameState.Playing;
///
/// Gets the current number of players assigned to the given team.
///
/// The team.
/// The number of players currently assigned to .
public int TeamCount(HeykelSavasiTeam team) => this._teams.Values.Count(t => t == team);
///
/// Determines whether a player may currently join the given team, based on the team-balance rule.
///
/// The team which a player wants to join.
/// true if the join is allowed; otherwise, false.
public bool CanJoin(HeykelSavasiTeam team) => IsJoinAllowed(this.TeamCount(HeykelSavasiTeam.Red), this.TeamCount(HeykelSavasiTeam.Blue), team);
///
/// Assigns the given player to the given team.
///
/// The player.
/// The team.
public void AssignTeam(Player player, HeykelSavasiTeam team) => this._teams[player] = team;
///
/// Gets the team of the given player.
///
/// The player.
/// The team of the player, or if the player is not assigned to a team.
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
///
/// Gets the base spawn gate for the given team.
///
/// The team.
/// The which is the base spawn point of .
///
/// The gate is resolved by matching the X1/Y1 anchor of the gates created for this event's map
/// (see Gates.cs targetGates 600/601, added in Task 0.3). Red anchors at (20, 20), Blue at (220, 220).
///
public ExitGate GetTeamSpawnGate(HeykelSavasiTeam team)
{
var (anchorX1, anchorY1) = team == HeykelSavasiTeam.Blue
? (BlueBaseAnchorX1, BlueBaseAnchorY1)
: (RedBaseAnchorX1, RedBaseAnchorY1);
return this.Definition.Entrance!.Map!.ExitGates.First(g => g.X1 == anchorX1 && g.Y1 == anchorY1);
}
///
/// Gets all players which are currently assigned to the given team.
///
/// The team.
/// All players assigned to .
public IEnumerable PlayersOf(HeykelSavasiTeam team) => this._teams.Where(kv => kv.Value == team).Select(kv => kv.Key);
///
/// Gets , exposed for unit tests only (see
/// HeykelSavasiContextTests.StatueBuffMap_HasSixEntriesInExpectedOrder); the array itself stays
/// private because it is an implementation detail of .
///
internal static short[] StatueBuffEffectNumbersForTest => StatueBuffEffectNumbers;
///
/// Gets the number of the given team's own statues which have been broken by the opposing team so far.
///
/// The attacking team.
/// The number of the opponent's statues has destroyed (0..).
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.GetProgress(attacker);
///
/// Gets the team which has won the event by breaking all of the opponent's statues, if any.
///
/// The winning team, or if the event hasn't been decided yet.
public HeykelSavasiTeam GetWinner() => this._progress.GetWinner();
///
/// Atomically attempts to join the given player to the given team, reserving a slot under
/// to fix a check-then-act race between the balance check and the team
/// assignment, then entering the mini game. If entering fails, the reservation is rolled back.
///
/// The player who wants to join.
/// The team which the player wants to join.
/// true if the player successfully joined and entered the mini game; otherwise, false.
public async ValueTask TryJoinTeamAsync(Player player, HeykelSavasiTeam team)
{
bool reserved;
lock (this._teamLock)
{
reserved = this.State == MiniGameState.Open
&& this.GetTeam(player) == HeykelSavasiTeam.None
&& this.CanJoin(team);
if (reserved)
{
// Reserve the slot synchronously so a concurrent join sees the updated team count.
this.AssignTeam(player, team);
}
}
if (!reserved)
{
return false;
}
var success = false;
try
{
var enterResult = await this.TryEnterAsync(player).ConfigureAwait(false);
success = enterResult == EnterResult.Success;
}
finally
{
if (!success)
{
lock (this._teamLock)
{
// Roll back the reservation; the player never actually entered the mini game
// (or TryEnterAsync threw, e.g. due to a disconnect/disposal race).
this.RemoveTeam(player);
}
}
}
return success;
}
///
/// Removes the given player's team reservation, if any.
///
/// The player.
private void RemoveTeam(Player player) => this._teams.TryRemove(player, out _);
///
///
/// Cancels the event if either team is empty at start (no opponent to fight); otherwise
/// warps every participant to their team's base spawn gate.
///
protected override async ValueTask OnGameStartAsync(ICollection players)
{
await base.OnGameStartAsync(players).ConfigureAwait(false);
if (this.TeamCount(HeykelSavasiTeam.Red) == 0 || this.TeamCount(HeykelSavasiTeam.Blue) == 0)
{
this.FinishEvent();
return;
}
foreach (var player in players)
{
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);
}
///
///
/// Note: already subscribes every
/// added to to
/// (see MiniGameContext.cs), so statues spawned via
/// are routed here automatically; this override must not
/// subscribe Died again.
///
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);
}
///
/// Registers, for tests only, that destroyed 's
/// statue at , without triggering any spawn/warp/buff side effects.
///
/// The team whose statue was destroyed.
/// The index (0..6) of the destroyed statue in 's line.
/// The team which destroyed the statue.
internal void RegisterStatueDestroyedForTest(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
=> this.HandleStatueDestroyed(defender, index, attacker, spawnNext: false);
///
/// Gets the fixed sequence of 7 statue positions defending 's own line (attacked
/// by the opposing team). Index 0 is the outermost/first statue, index 6 is the final one.
///
/// The defending team.
/// The 7 statue positions of 's line, or an empty array for .
///
/// This is deliberately kept in the GameLogic project (rather than
/// Persistence.Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs, as originally sketched in
/// the task brief) because MUnique.OpenMU.GameLogic does not - and must not - reference
/// MUnique.OpenMU.Persistence.Initialization; the dependency only goes the other way.
///
internal static Point[] StatuePositions(HeykelSavasiTeam team)
{
return team switch
{
HeykelSavasiTeam.Red => RedStatuePositions,
HeykelSavasiTeam.Blue => BlueStatuePositions,
_ => Array.Empty(),
};
}
///
/// Determines, in a pure way, whether joining is allowed given the current
/// team counts, according to the balance rule: the absolute difference between the red and blue
/// team sizes must not exceed after the prospective join.
///
/// The current number of players in the red team.
/// The current number of players in the blue team.
/// The team which a player wants to join.
/// true if the join is allowed; otherwise, false.
internal static bool IsJoinAllowed(int redCount, int blueCount, HeykelSavasiTeam team)
{
switch (team)
{
case HeykelSavasiTeam.Red:
redCount++;
break;
case HeykelSavasiTeam.Blue:
blueCount++;
break;
default:
return false;
}
return Math.Abs(redCount - blueCount) <= MaxTeamDifference;
}
///
/// Gets the opposing team of . maps to itself.
///
/// The team.
/// The opposing team.
private static HeykelSavasiTeam Opponent(HeykelSavasiTeam team) => team switch
{
HeykelSavasiTeam.Red => HeykelSavasiTeam.Blue,
HeykelSavasiTeam.Blue => HeykelSavasiTeam.Red,
_ => HeykelSavasiTeam.None,
};
///
/// Pure state transition shared by the runtime death handler () and the
/// test-only : records the attacker's progress and, once
/// statues have fallen, the winner. Spawning the next statue and applying
/// buffs are runtime-only side effects, triggered separately by the caller when
/// is true (see ).
///
/// The team whose statue was destroyed.
/// The index (0..6) of the destroyed statue in 's line.
/// The team which destroyed the statue.
///
/// Unused by this pure method; documents, at call sites, whether the caller intends to trigger the
/// runtime spawn-next/buff/finish side effects afterwards ( from
/// ) or not ( from tests).
///
private void HandleStatueDestroyed(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker, bool spawnNext)
{
_ = defender;
_ = spawnNext;
this._progress.RegisterDestroyed(index, attacker);
}
///
/// 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.
///
/// The team whose statue was destroyed.
/// The index (0..6) of the destroyed statue in 's line.
/// The team which destroyed the statue.
private async ValueTask OnStatueDestroyedSideEffectsAsync(HeykelSavasiTeam defender, int index, HeykelSavasiTeam attacker)
{
try
{
await this.ApplyStatueBuffToTeamAsync(attacker, index).ConfigureAwait(false);
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);
}
}
///
/// Spawns 's statue at in its line, plus 4 guard
/// mobs around it, using the same runtime-spawn API the map initializer itself uses
/// (; see also Castle Siege's
/// CastleSiegeEventPlugIn.SpawnCastleDefensesAsync for the reference pattern). The spawned statue
/// is registered in so can attribute its death to
/// the correct defender/index; its Died event does not need to be subscribed here because
/// already does so for every
/// added to .
///
/// The team whose statue line is being extended.
/// The index (0..6) of the statue to spawn in 's line.
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);
}
}
///
/// Applies the class buff mapped to (see )
/// to every player currently on . A no-op for the 7th statue (index 6, out of
/// range), which is the win condition rather than a buff trigger.
///
/// The attacking team, whose players receive the buff.
/// The index (0..6) of the statue that was just destroyed.
private async ValueTask ApplyStatueBuffToTeamAsync(HeykelSavasiTeam team, int statueIndex)
{
if (statueIndex < 0 || statueIndex >= StatueBuffEffectNumbers.Length)
{
return;
}
var effectNumber = StatueBuffEffectNumbers[statueIndex];
foreach (var player in this.PlayersOf(team).ToList())
{
await this.ApplyEffectAsync(player, effectNumber).ConfigureAwait(false);
}
}
///
/// Applies a single, already-existing (looked up by
/// ) to , for .
/// Mirrors the boost-construction pattern of ApplyMagicEffectConsumeHandlerPlugIn.ConsumeItemAsyncCore:
/// each of the definition's PowerUpDefinitions is turned into a boost element bound to the
/// player's own system, so the buff scales off that player's stats.
///
/// The player to buff.
/// The of the effect to apply.
private async ValueTask ApplyEffectAsync(Player player, short effectNumber)
{
var def = player.GameContext.Configuration.MagicEffects.FirstOrDefault(m => m.Number == effectNumber);
if (def is null || player.Attributes is null)
{
return;
}
var boosts = def.PowerUpDefinitions
.Where(d => d.Boost is not null && d.TargetAttribute is not null)
.Select(d => new MagicEffect.ElementWithTarget(player.Attributes.CreateElement(d), d.TargetAttribute!))
.ToArray();
if (boosts.Length == 0)
{
return;
}
var effect = new MagicEffect(BuffDuration, def, boosts!);
await player.MagicEffectList.AddEffectAsync(effect).ConfigureAwait(false);
}
///
/// Re-applies every buff 's team has already earned (one per statue broken so
/// far, indices [0..progress-1]) to . Intended for use on respawn (Task 5.3),
/// since the existing buffs are configured with StopByDeath=true and are cleared on death.
///
/// The player to re-buff.
internal async ValueTask ReapplyBuffsAsync(Player player)
{
var team = this.GetTeam(player);
var earned = this.GetProgress(team);
for (var i = 0; i < earned && i < StatueBuffEffectNumbers.Length; i++)
{
await this.ApplyEffectAsync(player, StatueBuffEffectNumbers[i]).ConfigureAwait(false);
}
}
/// Reapplies the team's earned buffs to a player who just (re)spawned.
/// The player who (re)spawned.
public ValueTask OnPlayerRespawnedAsync(Player player) => this.ReapplyBuffsAsync(player);
///
/// Pure, dependency-free tracker for statue-break progress and win detection. Extracted out of
/// so it can be unit-tested directly, without constructing a full
/// context (whose constructor requires a live /
/// and starts a background game loop via Task.Run - impractical in a fast unit test; see also
/// , extracted for the same reason). Kept internal (rather than
/// private) specifically so MUnique.OpenMU.Tests can drive it directly.
///
internal sealed class StatueProgressState
{
private readonly ConcurrentDictionary _progress = new()
{
[HeykelSavasiTeam.Red] = 0,
[HeykelSavasiTeam.Blue] = 0,
};
private HeykelSavasiTeam _winner = HeykelSavasiTeam.None;
///
/// Gets the number of the opponent's statues has destroyed so far.
///
/// The attacking team.
public int GetProgress(HeykelSavasiTeam attacker) => this._progress.TryGetValue(attacker, out var value) ? value : 0;
///
/// Gets the winning team, or if undecided.
///
public HeykelSavasiTeam GetWinner() => this._winner;
///
/// Registers that destroyed the statue at in
/// the opponent's line. Once a winner is set, further registrations are ignored.
///
/// The index (0..6) of the destroyed statue.
/// The team which destroyed the statue.
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;
}
}
}
}