feat(hs): raise statue HP, transform players in-battle, per-player team roster packet, drop wing-unequip

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Acentech Dev
2026-07-21 10:24:54 +03:00
parent 7aae9dff0c
commit 1e8478fe70
9 changed files with 511 additions and 95 deletions

View File

@@ -218,31 +218,6 @@ public class HeykelSavasiContext : MiniGameContext
/// </remarks>
public override bool AllowPlayerKilling => this.State == MiniGameState.Playing;
/// <inheritdoc />
/// <remarks>
/// Disallows wings and capes (see <see cref="IsWingOrCape"/>) once the battle has started
/// (<see cref="MiniGameState.Playing"/>), so a player cannot re-equip one after
/// <see cref="UnequipWingsAndCapesAsync"/> auto-unequipped it at battle start (see <see cref="OnGameStartAsync"/>).
/// This check is deliberately scoped to <see cref="MiniGameState.Playing"/> only (not <see cref="MiniGameState.Open"/>
/// or <see cref="MiniGameState.Closed"/>): <see cref="MiniGameContext.TryEnterAsync"/> calls this method (via its
/// private <c>AreEquippedItemsAllowedAsync</c>) while <see cref="MiniGameContext.State"/> is still
/// <see cref="MiniGameState.Open"/> to decide whether to REJECT entry outright for a disallowed equipped item -
/// which we do not want here (a player wearing wings should still be able to join; they get unequipped
/// automatically once the battle starts). Scoping the check to <see cref="MiniGameState.Playing"/> keeps entry
/// working while still blocking any (re-)equip attempt during the battle itself, via the other call site of
/// this method in <c>MoveItemAction.CanMoveAsync</c> (which checks <c>player.CurrentMiniGame.IsItemAllowedToEquip</c>
/// on every equip attempt, regardless of state).
/// </remarks>
public override bool IsItemAllowedToEquip(Item item)
{
if (this.State == MiniGameState.Playing && IsWingOrCape(item))
{
return false;
}
return base.IsItemAllowedToEquip(item);
}
/// <summary>
/// Gets the current number of players assigned to the given team.
/// </summary>
@@ -405,6 +380,9 @@ public class HeykelSavasiContext : MiniGameContext
{
if (args.Object is Player player)
{
// Revert the uniform battle form before dropping the player from the roster, so a player who
// leaves the event map mid-battle returns to their normal appearance.
await this.RemoveTransformAsync(player).ConfigureAwait(false);
this.RemoveTeam(player);
}
@@ -431,13 +409,18 @@ public class HeykelSavasiContext : MiniGameContext
await player.WarpToAsync(this.GetTeamSpawnGate(this.GetTeam(player))).ConfigureAwait(false);
}
// Players must be wingless for the battle; auto-unequip any wing/cape now that the entrance is closed
// and the teams are final (see IsItemAllowedToEquip/IsWingOrCape/UnequipWingsAndCapesAsync).
// Transform every participant into a single uniform battle form so their wings/mount/pet are
// visually hidden and everyone shares the same base skin; the client adds the red/blue tint on top
// via the per-player team roster (IHeykelSavasiTeamRosterPlugIn). Do this after the warp so the
// appearance broadcast reaches the players' new (base map) scope.
foreach (var player in players)
{
await this.UnequipWingsAndCapesAsync(player).ConfigureAwait(false);
await this.TransformAsync(player).ConfigureAwait(false);
}
// Re-show the HUD panel promptly after the battle-start map change.
await this.BroadcastHudStateAsync().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);
@@ -474,6 +457,12 @@ public class HeykelSavasiContext : MiniGameContext
}
}
// Revert the uniform battle form applied in OnGameStartAsync so players return to their normal appearance.
foreach (var player in finishers)
{
await this.RemoveTransformAsync(player).ConfigureAwait(false);
}
// Final HUD broadcast: by now MiniGameContext.State is already MiniGameState.Ended (StopAsync sets it
// before calling this method), so ComputeHudPhaseAndRemaining naturally reports phase 3/0 seconds. This
// is needed because the periodic loop (HudBroadcastLoopAsync) stops exactly at this same state
@@ -587,69 +576,6 @@ public class HeykelSavasiContext : MiniGameContext
_ => HeykelSavasiTeam.None,
};
/// <summary>
/// Determines whether <paramref name="item"/> is a wing or a cape, which are disallowed during the battle
/// (see <see cref="IsItemAllowedToEquip"/>) and auto-unequipped at battle start (see
/// <see cref="UnequipWingsAndCapesAsync"/>).
/// </summary>
/// <param name="item">The item to check.</param>
/// <returns><c>true</c> if <paramref name="item"/> is a wing or cape; otherwise, <c>false</c>.</returns>
/// <remarks>
/// Almost all wings AND capes share <c>ItemDefinition.Group</c> 12 (see
/// <c>Persistence.Initialization.VersionSeasonSix.Items.Wings.CreateWing</c>, which sets <c>wing.Group = 12;</c>
/// for every wing/cape it creates); the single exception is "Cape of Lord" (group 12, number 30 at creation),
/// which is deliberately reassigned to group 13 right after creation (<c>capeOfLord.Group = 13;</c> in
/// <c>Wings.Initialize</c>) so it is special-cased here by its fixed (group, number) pair.
/// </remarks>
private static bool IsWingOrCape(Item item)
{
if (item.Definition is not { } definition)
{
return false;
}
return (definition.Group, definition.Number) switch
{
(12, _) => true, // Wings, including most capes (Cape of Fighter/Emperor/Overrule, Poison/Warrior Cape, ...).
(13, 30) => true, // Cape of Lord, the one cape reassigned to its own item group.
_ => false,
};
}
/// <summary>
/// Auto-unequips any wing or cape (see <see cref="IsWingOrCape"/>) currently equipped by <paramref name="player"/>,
/// moving it to a free general inventory slot so its stats/appearance are removed (see
/// <see cref="InventoryStorage.EquippedItemsChanged"/>), without deleting it. Called from
/// <see cref="OnGameStartAsync"/> once the battle starts. If no free inventory slot is available, the item is
/// deliberately left equipped (logged) rather than risking data loss.
/// </summary>
/// <param name="player">The player whose equipped wings/capes should be unequipped.</param>
private async ValueTask UnequipWingsAndCapesAsync(Player player)
{
if (player.Inventory is not { } inventory)
{
return;
}
// Snapshot first: EquippedItems is a live view over the equip slots, and RemoveItemAsync below mutates it.
var wingItems = inventory.EquippedItems.Where(IsWingOrCape).ToList();
foreach (var item in wingItems)
{
var freeSlot = inventory.CheckInvSpace(item);
if (freeSlot is null)
{
this.Logger.LogWarning("{context}: Player {player} has no free inventory slot to unequip {item}; leaving it equipped.", this, player, item);
continue;
}
await inventory.RemoveItemAsync(item).ConfigureAwait(false);
if (!await inventory.AddItemAsync(freeSlot.Value, item).ConfigureAwait(false))
{
this.Logger.LogError("{context}: Failed to move unequipped item {item} of player {player} to inventory slot {slot} after removal from the equip slot; the item may now be lost.", this, item, player, freeSlot.Value);
}
}
}
/// <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
@@ -857,9 +783,69 @@ public class HeykelSavasiContext : MiniGameContext
}
}
/// <summary>Reapplies the team's earned buffs to a player who just (re)spawned.</summary>
/// <summary>
/// Reapplies the team's earned buffs to a player who just (re)spawned, and - while the battle is running -
/// re-transforms them into the uniform battle form (death clears the transformation skin) and re-shows the
/// HUD panel promptly after the respawn map change.
/// </summary>
/// <param name="player">The player who (re)spawned.</param>
public ValueTask OnPlayerRespawnedAsync(Player player) => this.ReapplyBuffsAsync(player);
public async ValueTask OnPlayerRespawnedAsync(Player player)
{
await this.ReapplyBuffsAsync(player).ConfigureAwait(false);
if (this.State == MiniGameState.Playing)
{
await this.TransformAsync(player).ConfigureAwait(false);
await this.BroadcastHudStateAsync().ConfigureAwait(false);
}
}
/// <summary>
/// The <see cref="MonsterDefinition.Number"/> of the transformation skin every participant is turned into
/// for the duration of the battle: 14 = Skeleton Warrior, a caped, walking humanoid soldier that reads as a
/// reasonable uniform battle form and hides the player's wings/mount/pet. The red/blue distinction is added
/// separately by the client via the per-player team roster (<see cref="IHeykelSavasiTeamRosterPlugIn"/>).
/// </summary>
private const short EventFormSkin = 14;
/// <summary>
/// Transforms <paramref name="player"/> into the uniform <see cref="EventFormSkin"/> battle form, mirroring
/// the exact mechanism of <c>SkinChatCommandPlugIn</c> (the <c>/skin</c> command): it composes an
/// <see cref="AggregateType.AddRaw"/> element onto the player's <see cref="Stats.TransformationSkin"/>
/// attribute and sets the attribute value, which fires <c>Player.OnTransformationSkinChanged</c> and
/// re-broadcasts the player's appearance to observers.
/// </summary>
/// <param name="player">The player to transform.</param>
private ValueTask TransformAsync(Player player) => this.SetTransformationSkinAsync(player, EventFormSkin);
/// <summary>
/// Reverts <paramref name="player"/>'s transformation by resetting <see cref="Stats.TransformationSkin"/> to
/// 0 (no transformation), which restores the player's normal appearance (wings/mount/pet included). Mirrors
/// how <c>/skin 0</c> removes the added skin element.
/// </summary>
/// <param name="player">The player to revert.</param>
private ValueTask RemoveTransformAsync(Player player) => this.SetTransformationSkinAsync(player, 0);
/// <summary>
/// Sets <paramref name="player"/>'s <see cref="Stats.TransformationSkin"/> to <paramref name="skin"/> exactly
/// as <c>SkinChatCommandPlugIn</c> does: clear any previously composed elements, add a single
/// <see cref="AggregateType.AddRaw"/> element with the target value, then write the attribute value (which is
/// what actually triggers the appearance re-broadcast). A value of 0 fully reverts to the natural appearance.
/// </summary>
/// <param name="player">The player whose transformation skin to set.</param>
/// <param name="skin">The transformation skin number (0 = none/revert).</param>
private ValueTask SetTransformationSkinAsync(Player player, short skin)
{
if (player.Attributes is { } attributes
&& attributes.GetComposableAttribute(Stats.TransformationSkin) is { } attribute)
{
attribute.Elements.ToList().ForEach(attribute.RemoveElement);
attribute.AddElement(attributes.CreateElement(new MUnique.OpenMU.Persistence.BasicModel.PowerUpDefinitionValue { AggregateType = MUnique.OpenMU.AttributeSystem.AggregateType.AddRaw, Value = skin }, Stats.TransformationSkin));
attributes[Stats.TransformationSkin] = skin;
}
return ValueTask.CompletedTask;
}
/// <summary>
/// Periodically (about once per second) broadcasts the Heykel Savasi HUD state
@@ -884,6 +870,7 @@ public class HeykelSavasiContext : MiniGameContext
do
{
await this.BroadcastHudStateAsync().ConfigureAwait(false);
await this.BroadcastTeamRosterAsync().ConfigureAwait(false);
}
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false));
}
@@ -917,6 +904,23 @@ public class HeykelSavasiContext : MiniGameContext
.AsTask()).ConfigureAwait(false);
}
/// <summary>
/// Sends the current team roster (every participating player's network id and team) to every entered
/// player via <see cref="IHeykelSavasiTeamRosterPlugIn"/>, so the client can tint each nearby event player
/// red or blue. Broadcast on the same ~1s cadence as the HUD state (see <see cref="HudBroadcastLoopAsync"/>).
/// </summary>
private async ValueTask BroadcastTeamRosterAsync()
{
var entries = this._teams
.Where(kv => kv.Value != HeykelSavasiTeam.None)
.Select(kv => (kv.Key.Id, (byte)kv.Value))
.ToList();
await this.ForEachPlayerAsync(player => player.InvokeViewPlugInAsync<IHeykelSavasiTeamRosterPlugIn>(p =>
p.UpdateTeamRosterAsync(entries))
.AsTask()).ConfigureAwait(false);
}
/// <summary>
/// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from
/// <see cref="MiniGameContext.State"/> and wall-clock timestamps recorded (lazily, on first observation) at

View File

@@ -0,0 +1,20 @@
// <copyright file="IHeykelSavasiTeamRosterPlugIn.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Views.MiniGames;
/// <summary>
/// Interface of a view whose implementation sends the Heykel Savasi (Statue War) team roster, mapping every
/// participating player's network id to its team so the client can tint each nearby player red or blue.
/// </summary>
public interface IHeykelSavasiTeamRosterPlugIn : IViewPlugIn
{
/// <summary>
/// Sends the current team roster.
/// </summary>
/// <param name="entries">
/// The (player network id, team) pairs of all participating players. Team is 1 = red, 2 = blue.
/// </param>
ValueTask UpdateTeamRosterAsync(IReadOnlyList<(ushort PlayerId, byte Team)> entries);
}