diff --git a/docs/Packets/C1-FC-HeykelSavasiTeamRoster_by-server.md b/docs/Packets/C1-FC-HeykelSavasiTeamRoster_by-server.md new file mode 100644 index 0000000..5e12d47 --- /dev/null +++ b/docs/Packets/C1-FC-HeykelSavasiTeamRoster_by-server.md @@ -0,0 +1,30 @@ +# C1 FC - HeykelSavasiTeamRoster (by server) + +## Is sent when + +Periodically (about once every one to two seconds) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played. + +## Causes the following actions on the client side + +The client tints each listed nearby player red or blue according to its team. + +## Structure + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) | +| 1 | 1 | Byte | | Packet header - length of the packet | +| 2 | 1 | Byte | 0xFC | Packet header - packet type identifier | +| 3 | 1 | Byte | | Count; The number of player-team entries which follow. | +| 4 | PlayerTeam.Length * Count | Array of PlayerTeam | | Players | + +### PlayerTeam Structure + +Maps a player's network id to its Heykel Savasi team. + +Length: 3 Bytes + +| Index | Length | Data Type | Value | Description | +|-------|--------|-----------|-------|-------------| +| 0 | 2 | ShortBigEndian | | PlayerId; The network id of the player (same id used in the viewport/appearance packets). | +| 2 | 1 | Byte | | Team; 1 = red, 2 = blue. | \ No newline at end of file diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md index 8fa178f..84cd066 100644 --- a/docs/Packets/ServerToClient.md +++ b/docs/Packets/ServerToClient.md @@ -243,3 +243,4 @@ * [C3 F9 01 - OpenNpcDialog (by server)](C3-F9-01-OpenNpcDialog_by-server.md) * [C1 FA - HeykelSavasiOpenTeamPanel (by server)](C1-FA-HeykelSavasiOpenTeamPanel_by-server.md) * [C1 FB - HeykelSavasiHudState (by server)](C1-FB-HeykelSavasiHudState_by-server.md) + * [C1 FC - HeykelSavasiTeamRoster (by server)](C1-FC-HeykelSavasiTeamRoster_by-server.md) diff --git a/src/GameLogic/MiniGames/HeykelSavasiContext.cs b/src/GameLogic/MiniGames/HeykelSavasiContext.cs index e17dca3..badc021 100644 --- a/src/GameLogic/MiniGames/HeykelSavasiContext.cs +++ b/src/GameLogic/MiniGames/HeykelSavasiContext.cs @@ -218,31 +218,6 @@ public class HeykelSavasiContext : MiniGameContext /// public override bool AllowPlayerKilling => this.State == MiniGameState.Playing; - /// - /// - /// Disallows wings and capes (see ) once the battle has started - /// (), so a player cannot re-equip one after - /// auto-unequipped it at battle start (see ). - /// This check is deliberately scoped to only (not - /// or ): calls this method (via its - /// private AreEquippedItemsAllowedAsync) while is still - /// 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 keeps entry - /// working while still blocking any (re-)equip attempt during the battle itself, via the other call site of - /// this method in MoveItemAction.CanMoveAsync (which checks player.CurrentMiniGame.IsItemAllowedToEquip - /// on every equip attempt, regardless of state). - /// - public override bool IsItemAllowedToEquip(Item item) - { - if (this.State == MiniGameState.Playing && IsWingOrCape(item)) - { - return false; - } - - return base.IsItemAllowedToEquip(item); - } - /// /// Gets the current number of players assigned to the given team. /// @@ -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, }; - /// - /// Determines whether is a wing or a cape, which are disallowed during the battle - /// (see ) and auto-unequipped at battle start (see - /// ). - /// - /// The item to check. - /// true if is a wing or cape; otherwise, false. - /// - /// Almost all wings AND capes share ItemDefinition.Group 12 (see - /// Persistence.Initialization.VersionSeasonSix.Items.Wings.CreateWing, which sets wing.Group = 12; - /// 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 (capeOfLord.Group = 13; in - /// Wings.Initialize) so it is special-cased here by its fixed (group, number) pair. - /// - 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, - }; - } - - /// - /// Auto-unequips any wing or cape (see ) currently equipped by , - /// moving it to a free general inventory slot so its stats/appearance are removed (see - /// ), without deleting it. Called from - /// once the battle starts. If no free inventory slot is available, the item is - /// deliberately left equipped (logged) rather than risking data loss. - /// - /// The player whose equipped wings/capes should be unequipped. - 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); - } - } - } - /// /// Pure state transition shared by the runtime death handler () and the /// test-only : records the attacker's progress and, once @@ -857,9 +783,69 @@ public class HeykelSavasiContext : MiniGameContext } } - /// Reapplies the team's earned buffs to a player who just (re)spawned. + /// + /// 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. + /// /// The player who (re)spawned. - 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); + } + } + + /// + /// The 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 (). + /// + private const short EventFormSkin = 14; + + /// + /// Transforms into the uniform battle form, mirroring + /// the exact mechanism of SkinChatCommandPlugIn (the /skin command): it composes an + /// element onto the player's + /// attribute and sets the attribute value, which fires Player.OnTransformationSkinChanged and + /// re-broadcasts the player's appearance to observers. + /// + /// The player to transform. + private ValueTask TransformAsync(Player player) => this.SetTransformationSkinAsync(player, EventFormSkin); + + /// + /// Reverts 's transformation by resetting to + /// 0 (no transformation), which restores the player's normal appearance (wings/mount/pet included). Mirrors + /// how /skin 0 removes the added skin element. + /// + /// The player to revert. + private ValueTask RemoveTransformAsync(Player player) => this.SetTransformationSkinAsync(player, 0); + + /// + /// Sets 's to exactly + /// as SkinChatCommandPlugIn does: clear any previously composed elements, add a single + /// 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. + /// + /// The player whose transformation skin to set. + /// The transformation skin number (0 = none/revert). + 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; + } /// /// 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); } + /// + /// Sends the current team roster (every participating player's network id and team) to every entered + /// player via , so the client can tint each nearby event player + /// red or blue. Broadcast on the same ~1s cadence as the HUD state (see ). + /// + 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(p => + p.UpdateTeamRosterAsync(entries)) + .AsTask()).ConfigureAwait(false); + } + /// /// Determines the current HUD phase and the estimated number of seconds remaining in it, purely from /// and wall-clock timestamps recorded (lazily, on first observation) at diff --git a/src/GameLogic/Views/MiniGames/IHeykelSavasiTeamRosterPlugIn.cs b/src/GameLogic/Views/MiniGames/IHeykelSavasiTeamRosterPlugIn.cs new file mode 100644 index 0000000..5b57acc --- /dev/null +++ b/src/GameLogic/Views/MiniGames/IHeykelSavasiTeamRosterPlugIn.cs @@ -0,0 +1,20 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.Views.MiniGames; + +/// +/// 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. +/// +public interface IHeykelSavasiTeamRosterPlugIn : IViewPlugIn +{ + /// + /// Sends the current team roster. + /// + /// + /// The (player network id, team) pairs of all participating players. Team is 1 = red, 2 = blue. + /// + ValueTask UpdateTeamRosterAsync(IReadOnlyList<(ushort PlayerId, byte Team)> entries); +} diff --git a/src/GameServer/RemoteView/MiniGames/HeykelSavasiTeamRosterPlugIn.cs b/src/GameServer/RemoteView/MiniGames/HeykelSavasiTeamRosterPlugIn.cs new file mode 100644 index 0000000..9eb3879 --- /dev/null +++ b/src/GameServer/RemoteView/MiniGames/HeykelSavasiTeamRosterPlugIn.cs @@ -0,0 +1,63 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameServer.RemoteView.MiniGames; + +using System.Runtime.InteropServices; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Views.MiniGames; +using MUnique.OpenMU.Network; +using MUnique.OpenMU.Network.Packets.ServerToClient; +using MUnique.OpenMU.PlugIns; + +/// +/// The default implementation of the which is forwarding the +/// team roster to the game client with a data packet (code 0xFC). +/// +[PlugIn] +[Guid("b1f2a6d4-1c7e-4d3a-9b0e-6a2f5c8e4d11")] +public class HeykelSavasiTeamRosterPlugIn : IHeykelSavasiTeamRosterPlugIn +{ + private readonly RemotePlayer _player; + + /// + /// Initializes a new instance of the class. + /// + /// The player. + public HeykelSavasiTeamRosterPlugIn(RemotePlayer player) => this._player = player; + + /// + public async ValueTask UpdateTeamRosterAsync(IReadOnlyList<(ushort PlayerId, byte Team)> entries) + { + var connection = this._player.Connection; + if (connection is null) + { + return; + } + + // A byte Count field caps the roster at 255 entries; the event never has that many players. + var count = entries.Count > byte.MaxValue ? byte.MaxValue : entries.Count; + + int Write() + { + var size = HeykelSavasiTeamRosterRef.GetRequiredSize(count); + var span = connection.Output.GetSpan(size)[..size]; + var packet = new HeykelSavasiTeamRosterRef(span) + { + Count = (byte)count, + }; + + for (var i = 0; i < count; i++) + { + var block = packet[i]; + block.PlayerId = entries[i].PlayerId; + block.Team = entries[i].Team; + } + + return size; + } + + await connection.SendAsync(Write).ConfigureAwait(false); + } +} diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs index 4d5d3b0..9b3abf0 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.cs @@ -30892,6 +30892,133 @@ public readonly struct HeykelSavasiHudState /// The packet as byte span. public static implicit operator Memory(HeykelSavasiHudState packet) => packet._data; } + + +/// +/// Is sent by the server when: Periodically (about once every one to two seconds) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played. +/// Causes reaction on client side: The client tints each listed nearby player red or blue according to its team. +/// +public readonly struct HeykelSavasiTeamRoster +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public HeykelSavasiTeamRoster(Memory data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private HeykelSavasiTeamRoster(Memory data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)data.Length; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xFC; + + /// + /// Gets the header of this packet. + /// + public C1Header Header => new (this._data); + + /// + /// Gets or sets the number of player-team entries which follow. + /// + public byte Count + { + get => this._data.Span[3]; + set => this._data.Span[3] = value; + } + + /// + /// Gets the of the specified index. + /// + public PlayerTeam this[int index] => new (this._data.Slice(4 + index * PlayerTeam.Length)); + + /// + /// Performs an implicit conversion from a Memory of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator HeykelSavasiTeamRoster(Memory packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Memory of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Memory(HeykelSavasiTeamRoster packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int playersCount) => playersCount * PlayerTeam.Length + 4; + + +/// +/// Maps a player's network id to its Heykel Savasi team.. +/// +public readonly struct PlayerTeam +{ + private readonly Memory _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public PlayerTeam(Memory data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 3; + + /// + /// Gets or sets the network id of the player (same id used in the viewport/appearance packets). + /// + public ushort PlayerId + { + get => ReadUInt16BigEndian(this._data.Span); + set => WriteUInt16BigEndian(this._data.Span, value); + } + + /// + /// Gets or sets 1 = red, 2 = blue. + /// + public byte Team + { + get => this._data.Span[2]; + set => this._data.Span[2] = value; + } +} +} /// /// Defines the role of a guild member. /// diff --git a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml index 02d62f5..48a6f7a 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPackets.xml +++ b/src/Network/Packets/ServerToClient/ServerToClientPackets.xml @@ -11139,6 +11139,50 @@ + + C1Header + FC + HeykelSavasiTeamRoster + ServerToClient + Periodically (about once every one to two seconds) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played. + The client tints each listed nearby player red or blue according to its team. + + + 3 + Byte + Count + The number of player-team entries which follow. + + + 4 + Structure[] + PlayerTeam + Players + Count + + + + + PlayerTeam + Maps a player's network id to its Heykel Savasi team. + 3 + + + 0 + ShortBigEndian + PlayerId + The network id of the player (same id used in the viewport/appearance packets). + + + 2 + Byte + Team + 1 = red, 2 = blue. + + + + + diff --git a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs index 81eb413..264a238 100644 --- a/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs +++ b/src/Network/Packets/ServerToClient/ServerToClientPacketsRef.cs @@ -29255,3 +29255,130 @@ public readonly ref struct HeykelSavasiHudStateRef /// The packet as byte span. public static implicit operator Span(HeykelSavasiHudStateRef packet) => packet._data; } + + +/// +/// Is sent by the server when: Periodically (about once every one to two seconds) while the Heykel Savasi (Statue War) event is open for registration, in its pre-battle countdown, or being played. +/// Causes reaction on client side: The client tints each listed nearby player red or blue according to its team. +/// +public readonly ref struct HeykelSavasiTeamRosterRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public HeykelSavasiTeamRosterRef(Span data) + : this(data, true) + { + } + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + /// If set to true, the header data is automatically initialized and written to the underlying span. + private HeykelSavasiTeamRosterRef(Span data, bool initialize) + { + this._data = data; + if (initialize) + { + var header = this.Header; + header.Type = HeaderType; + header.Code = Code; + header.Length = (byte)data.Length; + } + } + + /// + /// Gets the header type of this data packet. + /// + public static byte HeaderType => 0xC1; + + /// + /// Gets the operation code of this data packet. + /// + public static byte Code => 0xFC; + + /// + /// Gets the header of this packet. + /// + public C1HeaderRef Header => new (this._data); + + /// + /// Gets or sets the number of player-team entries which follow. + /// + public byte Count + { + get => this._data[3]; + set => this._data[3] = value; + } + + /// + /// Gets the of the specified index. + /// + public PlayerTeamRef this[int index] => new (this._data[(4 + index * PlayerTeamRef.Length)..]); + + /// + /// Performs an implicit conversion from a Span of bytes to a . + /// + /// The packet as span. + /// The packet as struct. + public static implicit operator HeykelSavasiTeamRosterRef(Span packet) => new (packet, false); + + /// + /// Performs an implicit conversion from to a Span of bytes. + /// + /// The packet as struct. + /// The packet as byte span. + public static implicit operator Span(HeykelSavasiTeamRosterRef packet) => packet._data; + + /// + /// Calculates the size of the packet for the specified count of . + /// + /// The count of from which the size will be calculated. + + public static int GetRequiredSize(int playersCount) => playersCount * PlayerTeamRef.Length + 4; + + +/// +/// Maps a player's network id to its Heykel Savasi team.. +/// +public readonly ref struct PlayerTeamRef +{ + private readonly Span _data; + + /// + /// Initializes a new instance of the struct. + /// + /// The underlying data. + public PlayerTeamRef(Span data) + { + this._data = data; + } + + /// + /// Gets the initial length of this data packet. When the size is dynamic, this value may be bigger than actually needed. + /// + public static int Length => 3; + + /// + /// Gets or sets the network id of the player (same id used in the viewport/appearance packets). + /// + public ushort PlayerId + { + get => ReadUInt16BigEndian(this._data); + set => WriteUInt16BigEndian(this._data, value); + } + + /// + /// Gets or sets 1 = red, 2 = blue. + /// + public byte Team + { + get => this._data[2]; + set => this._data[2] = value; + } +} +} diff --git a/src/Persistence/Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs b/src/Persistence/Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs index bca3769..132caaa 100644 --- a/src/Persistence/Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs +++ b/src/Persistence/Initialization/VersionSeasonSix/Maps/HeykelSavasiMap.cs @@ -77,8 +77,8 @@ internal class HeykelSavasiMap : BaseMapInitializer var statueAttributes = new Dictionary { { Stats.Level, 100 }, - { Stats.MaximumHealth, 200000 }, // tuning - { Stats.DefenseBase, 200 }, + { Stats.MaximumHealth, 3_000_000 }, // tuning: big HP piƱata that takes sustained effort to break + { Stats.DefenseBase, 500 }, { Stats.DefenseRatePvm, 100 }, }; statue.AddAttributes(statueAttributes, this.Context, this.GameConfiguration);