feat(hs): HeykelSavasiContext team tracking + balance rule

Adds the HeykelSavasiTeam enum (None/Red/Blue) and the HeykelSavasiContext
skeleton (subclass of MiniGameContext) with team registration
(AssignTeam/GetTeam/PlayersOf/TeamCount) and a join-balance rule
(|Red-Blue| <= 2 after the prospective join), exposed as a pure static
IsJoinAllowed for fast unit testing without constructing the full context.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-07-20 22:59:15 +03:00
parent 5d0f475472
commit 968eb0c155
3 changed files with 164 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
// <copyright file="HeykelSavasiContext.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using MUnique.OpenMU.DataModel.Configuration;
/// <summary>
/// The context of the Heykel Savasi (statue war) event.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
public class HeykelSavasiContext : MiniGameContext
{
/// <summary>
/// The maximum allowed difference between the red and blue team sizes after a join.
/// </summary>
private const int MaxTeamDifference = 2;
private readonly ConcurrentDictionary<Player, HeykelSavasiTeam> _teams = new();
/// <summary>
/// Initializes a new instance of the <see cref="HeykelSavasiContext"/> class.
/// </summary>
/// <param name="key">The key of this context.</param>
/// <param name="definition">The definition of the mini game.</param>
/// <param name="gameContext">The game context, to which this game belongs.</param>
/// <param name="mapInitializer">The map initializer, which is used when the event starts.</param>
public HeykelSavasiContext(MiniGameMapKey key, MiniGameDefinition definition, IGameContext gameContext, IMapInitializer mapInitializer)
: base(key, definition, gameContext, mapInitializer)
{
}
/// <summary>
/// Gets the current number of players assigned to the given team.
/// </summary>
/// <param name="team">The team.</param>
/// <returns>The number of players currently assigned to <paramref name="team"/>.</returns>
public int TeamCount(HeykelSavasiTeam team) => this._teams.Values.Count(t => t == team);
/// <summary>
/// Determines whether a player may currently join the given team, based on the team-balance rule.
/// </summary>
/// <param name="team">The team which a player wants to join.</param>
/// <returns><c>true</c> if the join is allowed; otherwise, <c>false</c>.</returns>
public bool CanJoin(HeykelSavasiTeam team) => IsJoinAllowed(this.TeamCount(HeykelSavasiTeam.Red), this.TeamCount(HeykelSavasiTeam.Blue), team);
/// <summary>
/// Assigns the given player to the given team.
/// </summary>
/// <param name="player">The player.</param>
/// <param name="team">The team.</param>
public void AssignTeam(Player player, HeykelSavasiTeam team) => this._teams[player] = team;
/// <summary>
/// Gets the team of the given player.
/// </summary>
/// <param name="player">The player.</param>
/// <returns>The team of the player, or <see cref="HeykelSavasiTeam.None"/> if the player is not assigned to a team.</returns>
public HeykelSavasiTeam GetTeam(Player player) => this._teams.TryGetValue(player, out var team) ? team : HeykelSavasiTeam.None;
/// <summary>
/// Gets all players which are currently assigned to the given team.
/// </summary>
/// <param name="team">The team.</param>
/// <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>
/// Determines, in a pure way, whether joining <paramref name="team"/> 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 <see cref="MaxTeamDifference"/> after the prospective join.
/// </summary>
/// <param name="redCount">The current number of players in the red team.</param>
/// <param name="blueCount">The current number of players in the blue team.</param>
/// <param name="team">The team which a player wants to join.</param>
/// <returns><c>true</c> if the join is allowed; otherwise, <c>false</c>.</returns>
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;
}
}

View File

@@ -0,0 +1,26 @@
// <copyright file="HeykelSavasiTeam.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.MiniGames;
/// <summary>
/// The team of a player participating in the Heykel Savasi event.
/// </summary>
public enum HeykelSavasiTeam
{
/// <summary>
/// The player is not assigned to any team.
/// </summary>
None,
/// <summary>
/// The red team.
/// </summary>
Red,
/// <summary>
/// The blue team.
/// </summary>
Blue,
}

View File

@@ -0,0 +1,37 @@
// <copyright file="HeykelSavasiContextTests.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.Tests.HeykelSavasi;
using MUnique.OpenMU.GameLogic.MiniGames;
/// <summary>
/// Tests for the Heykel Savasi team-balance rule.
/// </summary>
/// <remarks>
/// <see cref="HeykelSavasiContext"/> derives from <see cref="MiniGameContext"/>, whose constructor
/// creates a map and launches a background game loop via <c>Task.Run</c>. Constructing a full context
/// is impractical in a fast unit test, so the balance decision is exposed as a pure static method,
/// <see cref="HeykelSavasiContext.IsJoinAllowed"/>, and tested directly against it.
/// </remarks>
[TestFixture]
public class HeykelSavasiContextTests
{
[Test]
public void BalanceRule_BlocksJoinWhenDifferenceWouldExceedTwo()
{
// Red 0, Blue 0 -> either team may be joined.
Assert.That(HeykelSavasiContext.IsJoinAllowed(0, 0, HeykelSavasiTeam.Red), Is.True);
// Red already has 2, Blue has 0. Adding a 3rd to Red would make the difference 3.
Assert.That(HeykelSavasiContext.IsJoinAllowed(2, 0, HeykelSavasiTeam.Red), Is.False, "2-0 iken Red'e katilim farki 3 yapar");
Assert.That(HeykelSavasiContext.IsJoinAllowed(2, 0, HeykelSavasiTeam.Blue), Is.True);
}
[Test]
public void BalanceRule_NoneTeamIsNeverJoinable()
{
Assert.That(HeykelSavasiContext.IsJoinAllowed(0, 0, HeykelSavasiTeam.None), Is.False);
}
}