diff --git a/docs/Packets/C1-BD-00-CrywolfInfo_by-server.md b/docs/Packets/C1-BD-00-CrywolfInfo_by-server.md
new file mode 100644
index 0000000000..42d7206a2c
--- /dev/null
+++ b/docs/Packets/C1-BD-00-CrywolfInfo_by-server.md
@@ -0,0 +1,44 @@
+# C1 BD 00 - CrywolfInfo (by server)
+
+## Is sent when
+
+The state of the crywolf event changed, or the player entered the crywolf map. It should only be sent to players on the crywolf map, because the client loads the terrain of the occupation state for its current map.
+
+## Causes the following actions on the client side
+
+The client shows the event window in the states Ready, Start and End, plays the intro in the state Notify2, shows the result in the state End, and loads the terrain and light of the occupation state.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 6 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x00 | Packet header - sub packet type identifier |
+| 4 | 1 | OccupationState | | Occupation; The occupation state. It must be Peace or Occupied in the state End, otherwise the client shows no result. |
+| 5 | 1 | CrywolfState | | State; The state of the event. |
+
+### OccupationState Enum
+
+The occupation state of the crywolf fortress.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | Peace | The fortress has been defended. The benefits apply. |
+| 1 | Occupied | The fortress has been occupied by Balgass. The penalties apply. |
+| 2 | War | The fortress is under attack. |
+
+### CrywolfState Enum
+
+The state of the crywolf event.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | None | The event is not running. |
+| 1 | Notify1 | The first notification of the upcoming event. |
+| 2 | Notify2 | The second notification. The client plays the intro of the event. |
+| 3 | Ready | The altars can be contracted and the monsters appeared. |
+| 4 | Start | The monsters attack. |
+| 5 | End | The event ended. The client shows the result. |
+| 6 | EndCycle | The event is finished. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-02-CrywolfStatueAndAltarInfo_by-server.md b/docs/Packets/C1-BD-02-CrywolfStatueAndAltarInfo_by-server.md
new file mode 100644
index 0000000000..fc5f2f0f4c
--- /dev/null
+++ b/docs/Packets/C1-BD-02-CrywolfStatueAndAltarInfo_by-server.md
@@ -0,0 +1,24 @@
+# C1 BD 02 - CrywolfStatueAndAltarInfo (by server)
+
+## Is sent when
+
+Every two seconds while the altars can be contracted and during the battle.
+
+## Causes the following actions on the client side
+
+The client shows the shield of the statue and the states of the altars.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 16 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x02 | Packet header - sub packet type identifier |
+| 4 | 4 | IntegerLittleEndian | | StatueHealthPercent; The shield of the statue in percent, from 0 to 100. |
+| 8 | 1 | Byte | | Altar1State; The state of the first altar (205). The high nibble is the altar state (1 = contracted), the low nibble the number of remaining contracts. |
+| 9 | 1 | Byte | | Altar2State; The state of the second altar (206), like Altar1State. |
+| 10 | 1 | Byte | | Altar3State; The state of the third altar (207), like Altar1State. |
+| 11 | 1 | Byte | | Altar4State; The state of the fourth altar (208), like Altar1State. |
+| 12 | 1 | Byte | | Altar5State; The state of the fifth altar (209), like Altar1State. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-03-CrywolfContractResult_by-server.md b/docs/Packets/C1-BD-03-CrywolfContractResult_by-server.md
new file mode 100644
index 0000000000..4b270c6285
--- /dev/null
+++ b/docs/Packets/C1-BD-03-CrywolfContractResult_by-server.md
@@ -0,0 +1,21 @@
+# C1 BD 03 - CrywolfContractResult (by server)
+
+## Is sent when
+
+The player requested to contract an altar.
+
+## Causes the following actions on the client side
+
+On success, the client shows that the player is a guardian of the altar, and lets the character pray. Otherwise, it shows why the contract failed.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 8 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x03 | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | Result; The result of the contract: 1 when it was accepted, 0 otherwise. The client compares the whole byte, so it's no boolean field. |
+| 5 | 1 | Byte | | AltarState; The new state of the altar, like in the CrywolfStatueAndAltarInfo. |
+| 6 | 2 | ShortBigEndian | | AltarKey; The key of the altar. The client uses the key minus 317 as index into its five altar states without checking the range, so it must be 317 plus the index of the altar (0 to 4). |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-04-CrywolfLeftTime_by-server.md b/docs/Packets/C1-BD-04-CrywolfLeftTime_by-server.md
new file mode 100644
index 0000000000..4355dc1572
--- /dev/null
+++ b/docs/Packets/C1-BD-04-CrywolfLeftTime_by-server.md
@@ -0,0 +1,20 @@
+# C1 BD 04 - CrywolfLeftTime (by server)
+
+## Is sent when
+
+Every 20 seconds during the battle.
+
+## Causes the following actions on the client side
+
+The client shows the remaining minutes and counts down the seconds by itself. It expects this packet every 20 seconds: when the minute is the same as before, it assumes that 40 or 20 seconds of the minute are left.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 6 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x04 | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | Hours; The remaining hours. The client doesn't show them. |
+| 5 | 1 | Byte | | Minutes; The remaining minutes. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-05-CrywolfBossMonsterInfo_by-server.md b/docs/Packets/C1-BD-05-CrywolfBossMonsterInfo_by-server.md
new file mode 100644
index 0000000000..fe5c047773
--- /dev/null
+++ b/docs/Packets/C1-BD-05-CrywolfBossMonsterInfo_by-server.md
@@ -0,0 +1,20 @@
+# C1 BD 05 - CrywolfBossMonsterInfo (by server)
+
+## Is sent when
+
+Every five seconds during the battle.
+
+## Causes the following actions on the client side
+
+The client shows the health of Balgass and the number of Dark Elves.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 12 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x05 | Packet header - sub packet type identifier |
+| 4 | 4 | IntegerLittleEndian | | BalgassHealthPercent; The health of Balgass in percent, or -1, when Balgass is not alive. |
+| 8 | 1 | Byte | | DarkElfCount; The number of alive Dark Elves. The client shows it as count of 12. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-07-CrywolfPersonalRank_by-server.md b/docs/Packets/C1-BD-07-CrywolfPersonalRank_by-server.md
new file mode 100644
index 0000000000..d16994eb68
--- /dev/null
+++ b/docs/Packets/C1-BD-07-CrywolfPersonalRank_by-server.md
@@ -0,0 +1,32 @@
+# C1 BD 07 - CrywolfPersonalRank (by server)
+
+## Is sent when
+
+The crywolf event ended, after the CrywolfInfo with the state End.
+
+## Causes the following actions on the client side
+
+The client shows the rank and the experience of the player in the result.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 12 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x07 | Packet header - sub packet type identifier |
+| 4 | 1 | CrywolfRank | | Rank; The rank of the player. |
+| 8 | 4 | IntegerLittleEndian | | Experience; The rewarded experience. The field is aligned to 4 bytes, because the client structure is not packed. |
+
+### CrywolfRank Enum
+
+The rank of a player in the crywolf event.
+
+| Value | Name | Description |
+|-------|------|-------------|
+| 0 | D | The rank D. |
+| 1 | C | The rank C. |
+| 2 | B | The rank B. |
+| 3 | A | The rank A. |
+| 4 | S | The rank S. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-08-CrywolfHeroList_by-server.md b/docs/Packets/C1-BD-08-CrywolfHeroList_by-server.md
new file mode 100644
index 0000000000..b7a9cd3372
--- /dev/null
+++ b/docs/Packets/C1-BD-08-CrywolfHeroList_by-server.md
@@ -0,0 +1,33 @@
+# C1 BD 08 - CrywolfHeroList (by server)
+
+## Is sent when
+
+The crywolf event ended, after the CrywolfInfo with the state End.
+
+## Causes the following actions on the client side
+
+The client shows the heroes with the highest scores in the result.
+
+## 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 | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x08 | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | HeroCount; The number of heroes, at most 5. |
+| 5 | Hero.Length * HeroCount | Array of Hero | | Heroes; The heroes. |
+
+### Hero Structure
+
+Contains a hero of the crywolf event.
+
+Length: 20 Bytes
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | | Rank; The place of the hero, from 0 (first) to 4. The client uses it as array index, so it must not be greater than 4. |
+| 1 | 10 | String | | Name; The name of the hero. |
+| 12 | 4 | IntegerLittleEndian | | Score; The score of the hero. The field is aligned to 4 bytes, because the client structure is not packed. |
+| 16 | 1 | Byte | | CharacterClass; The number of the character class. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-09-CrywolfBenefitPlusChaosRate_by-server.md b/docs/Packets/C1-BD-09-CrywolfBenefitPlusChaosRate_by-server.md
new file mode 100644
index 0000000000..c17473f039
--- /dev/null
+++ b/docs/Packets/C1-BD-09-CrywolfBenefitPlusChaosRate_by-server.md
@@ -0,0 +1,19 @@
+# C1 BD 09 - CrywolfBenefitPlusChaosRate (by server)
+
+## Is sent when
+
+The player opened a crafting dialog and requested the chaos rate benefit of the crywolf event.
+
+## Causes the following actions on the client side
+
+The client shows the additional success rate of the crafting.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 5 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x09 | Packet header - sub packet type identifier |
+| 4 | 1 | Byte | | PlusChaosRate; The additional success rate in percent. |
\ No newline at end of file
diff --git a/docs/Packets/C1-BD-0C-CrywolfRegionMonsterAttack_by-server.md b/docs/Packets/C1-BD-0C-CrywolfRegionMonsterAttack_by-server.md
new file mode 100644
index 0000000000..63e85e57c9
--- /dev/null
+++ b/docs/Packets/C1-BD-0C-CrywolfRegionMonsterAttack_by-server.md
@@ -0,0 +1,23 @@
+# C1 BD 0C - CrywolfRegionMonsterAttack (by server)
+
+## Is sent when
+
+A ballista of the crywolf event attacks an area.
+
+## Causes the following actions on the client side
+
+The client shows an arrow which hits the target point. The direction of the arrow depends on the x coordinate of the ballista.
+
+## Structure
+
+| Index | Length | Data Type | Value | Description |
+|-------|--------|-----------|-------|-------------|
+| 0 | 1 | Byte | 0xC1 | [Packet type](PacketTypes.md) |
+| 1 | 1 | Byte | 10 | Packet header - length of the packet |
+| 2 | 1 | Byte | 0xBD | Packet header - packet type identifier |
+| 3 | 1 | Byte | 0x0C | Packet header - sub packet type identifier |
+| 4 | 2 | ShortBigEndian | | MonsterNumber; The number of the monster. The client ignores it. |
+| 6 | 1 | Byte | | SourceX; The x coordinate of the ballista. |
+| 7 | 1 | Byte | | SourceY; The y coordinate of the ballista. The client ignores it. |
+| 8 | 1 | Byte | | TargetX; The x coordinate of the target point. |
+| 9 | 1 | Byte | | TargetY; The y coordinate of the target point. |
\ No newline at end of file
diff --git a/docs/Packets/ServerToClient.md b/docs/Packets/ServerToClient.md
index 567d430370..a223325ce2 100644
--- a/docs/Packets/ServerToClient.md
+++ b/docs/Packets/ServerToClient.md
@@ -190,6 +190,15 @@
* [C1 B9 05 - CastleSiegeHuntingZoneEnterResponse (by server)](C1-B9-05-CastleSiegeHuntingZoneEnterResponse_by-server.md)
* [C1 BA - SkillStageUpdate (by server)](C1-BA-SkillStageUpdate_by-server.md)
* [C2 BB - CastleSiegeMiniMapNpcPositions (by server)](C2-BB-CastleSiegeMiniMapNpcPositions_by-server.md)
+ * [C1 BD 00 - CrywolfInfo (by server)](C1-BD-00-CrywolfInfo_by-server.md)
+ * [C1 BD 02 - CrywolfStatueAndAltarInfo (by server)](C1-BD-02-CrywolfStatueAndAltarInfo_by-server.md)
+ * [C1 BD 03 - CrywolfContractResult (by server)](C1-BD-03-CrywolfContractResult_by-server.md)
+ * [C1 BD 04 - CrywolfLeftTime (by server)](C1-BD-04-CrywolfLeftTime_by-server.md)
+ * [C1 BD 05 - CrywolfBossMonsterInfo (by server)](C1-BD-05-CrywolfBossMonsterInfo_by-server.md)
+ * [C1 BD 07 - CrywolfPersonalRank (by server)](C1-BD-07-CrywolfPersonalRank_by-server.md)
+ * [C1 BD 08 - CrywolfHeroList (by server)](C1-BD-08-CrywolfHeroList_by-server.md)
+ * [C1 BD 09 - CrywolfBenefitPlusChaosRate (by server)](C1-BD-09-CrywolfBenefitPlusChaosRate_by-server.md)
+ * [C1 BD 0C - CrywolfRegionMonsterAttack (by server)](C1-BD-0C-CrywolfRegionMonsterAttack_by-server.md)
* [C1 BF 00 - IllusionTempleEnterResult (by server)](C1-BF-00-IllusionTempleEnterResult_by-server.md)
* [C1 BF 01 - IllusionTempleState (by server)](C1-BF-01-IllusionTempleState_by-server.md)
* [C1 BF 02 - IllusionTempleSkillUsageResult (by server)](C1-BF-02-IllusionTempleSkillUsageResult_by-server.md)
diff --git a/docs/Progress.md b/docs/Progress.md
index 8b06a21758..025f299a82 100644
--- a/docs/Progress.md
+++ b/docs/Progress.md
@@ -83,7 +83,7 @@ complexity and effort). Complexity 0 means we wont implement it.
| *LoggedIn* | 0xB8 | 100% | 1 | |
| RequestGuildMarkCastleOwner | 0xB9 | 0% | 1 | |
| *JewelMix* | 0xBC | 100% | 4 | |
-| CrywolfGroup | 0xBD | 0% | 10 | |
+| *CrywolfGroup* | 0xBD | 100% | 10 | |
| GuildAssignStatus | 0xBE | 0% | 1 | |
| FriendListRequest | 0xC0 | 0% | 0 | Not needed, friend list is sent automatically |
| *FriendAdd* | 0xC1 | 100% | 2 | |
diff --git a/src/DataModel/Configuration/GameMapDefinition.cs b/src/DataModel/Configuration/GameMapDefinition.cs
index fec41ba2f9..33baac3093 100644
--- a/src/DataModel/Configuration/GameMapDefinition.cs
+++ b/src/DataModel/Configuration/GameMapDefinition.cs
@@ -44,6 +44,13 @@ public partial class GameMapDefinition
///
public byte[]? TerrainData { get; set; }
+ ///
+ /// Gets or sets the variants of the terrain, which replace the in certain states
+ /// of the map, e.g. during an event.
+ ///
+ [MemberOfAggregate]
+ public virtual ICollection TerrainVariants { get; protected set; } = null!;
+
///
/// Gets or sets the defined monster spawn areas.
///
diff --git a/src/DataModel/Configuration/GameMapTerrainVariant.cs b/src/DataModel/Configuration/GameMapTerrainVariant.cs
new file mode 100644
index 0000000000..a8e2ca1f08
--- /dev/null
+++ b/src/DataModel/Configuration/GameMapTerrainVariant.cs
@@ -0,0 +1,39 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Configuration;
+
+using MUnique.OpenMU.Annotations;
+
+///
+/// Defines an alternative terrain of a map, which replaces its
+/// in a certain state, e.g. while the crywolf fortress is occupied.
+///
+[Cloneable]
+public partial class GameMapTerrainVariant
+{
+ ///
+ /// Gets or sets the number, which identifies the variant on its map, e.g. the state in which it applies.
+ ///
+ public short Number { get; set; }
+
+ ///
+ /// Gets or sets the description.
+ ///
+ public string Description { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the terrain data.
+ ///
+ ///
+ /// Content of the *.att file in the original server.
+ ///
+ public byte[]? TerrainData { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.Number}: {this.Description}";
+ }
+}
diff --git a/src/DataModel/Entities/CrywolfData.cs b/src/DataModel/Entities/CrywolfData.cs
new file mode 100644
index 0000000000..6493e80966
--- /dev/null
+++ b/src/DataModel/Entities/CrywolfData.cs
@@ -0,0 +1,48 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.DataModel.Entities;
+
+///
+/// Persistent state of the crywolf event, stored as a single row, like the original game does it.
+/// It keeps the result of the last event until the next one.
+///
+///
+/// It's written by the game server which runs the event, and read by the other game servers,
+/// so that the occupation state and its benefits and penalties apply on all of them.
+///
+[AggregateRoot]
+public class CrywolfData
+{
+ ///
+ /// Gets or sets the unique identifier of this record.
+ ///
+ public Guid Id { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the crywolf fortress is occupied by Balgass,
+ /// because the last event failed. Otherwise, it's in peace.
+ ///
+ public bool IsOccupied { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the war is running, from the preparation of the fortress
+ /// until the end of the battle. Meanwhile, neither the benefits nor the penalties apply.
+ ///
+ public bool IsWarRunning { get; set; }
+
+ ///
+ /// Gets or sets the time (UTC) when the war started, or if it isn't running.
+ ///
+ ///
+ /// The other game servers consider the war as ended after the longest possible duration of the event,
+ /// in case the game server which runs it stopped during the war.
+ ///
+ public DateTime? WarStart { get; set; }
+
+ ///
+ /// Gets or sets the time (UTC) when the last battle ended, or if there was no battle yet.
+ ///
+ public DateTime? LastBattleEnd { get; set; }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfAltar.cs b/src/GameLogic/Crywolf/CrywolfAltar.cs
new file mode 100644
index 0000000000..2b4f01f443
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfAltar.cs
@@ -0,0 +1,220 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+
+///
+/// An altar of the crywolf event, which an elf contracts to protect the statue of the holy wolf.
+///
+///
+/// An elf has to stand at the altar and stay there until the contract is valid. When the elf moves,
+/// dies or leaves, the contract is cancelled. Each altar can be contracted only a few times per event,
+/// and the attempts count as well, like in the original game.
+///
+public sealed class CrywolfAltar
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The index of the altar, from 0 to 4.
+ /// The NPC of the altar.
+ public CrywolfAltar(int index, NonPlayerCharacter npc)
+ {
+ this.Index = index;
+ this.Npc = npc;
+ this.Display = new CrywolfEffectDisplay(npc);
+ }
+
+ ///
+ /// Gets the index of the altar, from 0 to 4.
+ ///
+ public int Index { get; }
+
+ ///
+ /// Gets the NPC of the altar.
+ ///
+ public NonPlayerCharacter Npc { get; }
+
+ ///
+ /// Gets the state of the altar.
+ ///
+ public CrywolfAltarState State { get; private set; }
+
+ ///
+ /// Gets the number of contracts of this event, including the attempts.
+ ///
+ public int ContractCount { get; private set; }
+
+ ///
+ /// Gets the elf which contracted the altar or tries to contract it.
+ ///
+ public Player? Contractor { get; private set; }
+
+ ///
+ /// Gets the display of the effect of the altar.
+ ///
+ internal CrywolfEffectDisplay Display { get; }
+
+ private DateTime ValidFrom { get; set; }
+
+ private DateTime LastChange { get; set; } = DateTime.MinValue;
+
+ ///
+ /// Gets the state of the altar as it's expected by the client: the altar state in the high nibble,
+ /// and the number of remaining contracts in the low nibble.
+ ///
+ /// The number of contracts per altar.
+ /// The state for the client.
+ public byte GetClientState(int contractsPerAltar)
+ {
+ var remainingContracts = Math.Clamp(contractsPerAltar - this.ContractCount, 0, 0x0F);
+ return (byte)(((byte)this.State << 4) | remainingContracts);
+ }
+
+ ///
+ /// Resets the altar for a new event.
+ ///
+ public void Reset()
+ {
+ this.State = CrywolfAltarState.Free;
+ this.ContractCount = 0;
+ this.Contractor = null;
+ this.LastChange = DateTime.MinValue;
+ this.UpdateEffect(true);
+ }
+
+ ///
+ /// Hides the effect of the altar, e.g. when the event is finished.
+ ///
+ public void Hide()
+ {
+ this.UpdateEffect(false);
+ }
+
+ ///
+ /// Tries to start a contract with an elf.
+ ///
+ /// The player of the elf.
+ /// The definition of the event.
+ /// The current time.
+ /// The result.
+ public CrywolfContractResult TryStartContract(Player player, CrywolfEventDefinition definition, DateTime now)
+ {
+ if (this.Contractor == player)
+ {
+ return CrywolfContractResult.AlreadyContracting;
+ }
+
+ if (this.State != CrywolfAltarState.Free)
+ {
+ return CrywolfContractResult.NotAvailable;
+ }
+
+ if (player.SelectedCharacter?.CharacterClass is not { } characterClass
+ || !definition.ContractCharacterClassNumbers.Contains(characterClass.Number)
+ || (player.Attributes?[Stats.Level] ?? 0) < definition.MinimumContractLevel)
+ {
+ return CrywolfContractResult.NotQualified;
+ }
+
+ if (IsMounted(player))
+ {
+ return CrywolfContractResult.Mounted;
+ }
+
+ if (now - this.LastChange < definition.AltarCooldown)
+ {
+ return CrywolfContractResult.Cooldown;
+ }
+
+ if (player.CurrentMap != this.Npc.CurrentMap || player.Position != this.Npc.Position)
+ {
+ return CrywolfContractResult.WrongPosition;
+ }
+
+ // Like in the original game, the attempt already counts as contract.
+ this.ContractCount++;
+ this.State = CrywolfAltarState.Attempting;
+ this.Contractor = player;
+ this.ValidFrom = now + definition.ContractDelay;
+ this.LastChange = now;
+ this.UpdateEffect(true);
+ return CrywolfContractResult.Success;
+ }
+
+ ///
+ /// Checks the contractor of the altar: the contract gets valid when the elf stayed at the altar long enough,
+ /// and it's cancelled when the elf moved, died or left.
+ ///
+ /// The definition of the event.
+ /// The current time.
+ /// The change of the contract.
+ public CrywolfContractChange Update(CrywolfEventDefinition definition, DateTime now)
+ {
+ if (this.Contractor is not { } contractor)
+ {
+ return CrywolfContractChange.None;
+ }
+
+ if (!IsAtAltar(contractor, this.Npc))
+ {
+ this.Cancel(definition, now);
+ return CrywolfContractChange.Cancelled;
+ }
+
+ if (this.State == CrywolfAltarState.Attempting && now >= this.ValidFrom)
+ {
+ this.State = CrywolfAltarState.Contracted;
+ this.UpdateEffect(true);
+ return CrywolfContractChange.Validated;
+ }
+
+ return CrywolfContractChange.None;
+ }
+
+ ///
+ /// Cancels the contract of the altar.
+ ///
+ /// The definition of the event.
+ /// The current time.
+ public void Cancel(CrywolfEventDefinition definition, DateTime now)
+ {
+ this.Contractor = null;
+ this.LastChange = now;
+ this.State = this.ContractCount >= definition.ContractsPerAltar ? CrywolfAltarState.Exhausted : CrywolfAltarState.Free;
+ this.UpdateEffect(true);
+ }
+
+ ///
+ /// Determines whether the player rides a mount (Uniria, Dinorant or Fenrir), like the client checks it.
+ ///
+ private static bool IsMounted(Player player)
+ {
+ return player.Inventory?.GetItem(InventoryConstants.PetSlot)?.Definition is { Group: 13, Number: 2 or 3 or 37 };
+ }
+
+ private static bool IsAtAltar(Player player, NonPlayerCharacter altar)
+ {
+ return player.IsAlive
+ && player.PlayerState.CurrentState == PlayerState.EnteredWorld
+ && player.CurrentMap == altar.CurrentMap
+ && player.Position == altar.Position;
+ }
+
+ private void UpdateEffect(bool isVisible)
+ {
+ this.Display.Effect = !isVisible
+ ? null
+ : this.State switch
+ {
+ CrywolfAltarState.Free => CrywolfEffect.AltarEnabled,
+ CrywolfAltarState.Contracted => CrywolfEffect.AltarContracted,
+ CrywolfAltarState.Attempting => CrywolfEffect.AltarAttempt,
+ _ => CrywolfEffect.AltarDisabled,
+ };
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfAltarState.cs b/src/GameLogic/Crywolf/CrywolfAltarState.cs
new file mode 100644
index 0000000000..c889b4f51c
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfAltarState.cs
@@ -0,0 +1,31 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The state of an altar of the crywolf event, matching the values expected by the client.
+///
+public enum CrywolfAltarState : byte
+{
+ ///
+ /// The altar can be contracted.
+ ///
+ Free = 0,
+
+ ///
+ /// The altar is contracted by an elf, which protects the statue.
+ ///
+ Contracted = 1,
+
+ ///
+ /// An elf is trying to contract the altar.
+ ///
+ Attempting = 2,
+
+ ///
+ /// The altar can't be contracted anymore, because all of its contracts were used.
+ ///
+ Exhausted = 3,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfArmyStage.cs b/src/GameLogic/Crywolf/CrywolfArmyStage.cs
new file mode 100644
index 0000000000..ff41a46239
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfArmyStage.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The stage of the army of Balgass during the crywolf event.
+///
+public enum CrywolfArmyStage
+{
+ ///
+ /// The army waits for the battle and doesn't move or attack.
+ ///
+ Waiting,
+
+ ///
+ /// The army advances towards the fortress.
+ ///
+ Advancing,
+
+ ///
+ /// The army attacks the statue of the holy wolf.
+ ///
+ AttackingStatue,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfBallista.cs b/src/GameLogic/Crywolf/CrywolfBallista.cs
new file mode 100644
index 0000000000..89dd222b3a
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfBallista.cs
@@ -0,0 +1,40 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// A ballista of the army of Balgass, which bombards a point of the fortress.
+///
+///
+/// The ballista is identified by the coordinates of its monster spawn.
+///
+public class CrywolfBallista
+{
+ ///
+ /// Gets or sets the x coordinate of the monster spawn of the ballista.
+ ///
+ public byte X { get; set; }
+
+ ///
+ /// Gets or sets the y coordinate of the monster spawn of the ballista.
+ ///
+ public byte Y { get; set; }
+
+ ///
+ /// Gets or sets the x coordinate of the point, which the ballista bombards.
+ ///
+ public byte TargetX { get; set; }
+
+ ///
+ /// Gets or sets the y coordinate of the point, which the ballista bombards.
+ ///
+ public byte TargetY { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"({this.X}, {this.Y}) -> ({this.TargetX}, {this.TargetY})";
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfContext.cs b/src/GameLogic/Crywolf/CrywolfContext.cs
new file mode 100644
index 0000000000..4f9bc1ab3b
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfContext.cs
@@ -0,0 +1,1438 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using System.Collections.Concurrent;
+using System.Threading;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.Properties;
+using MUnique.OpenMU.GameLogic.Views;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The context of the crywolf event of a game server.
+///
+///
+/// Unlike the mini games, the event takes place on the (only) instance of the crywolf map. Its run is:
+///
+/// The players are notified about the upcoming attack of the army of Balgass ().
+/// The fortress prepares for the war: the common monsters and NPCs of the map disappear ().
+/// The army appears and the elves contract the altars, which protect the statue ().
+/// The army attacks. Later, Balgass appears. When he's killed, the fortress has been defended ().
+/// The result is shown, and the players get experience depending on their score ().
+/// The army disappears and the common monsters return. The heroes are shown ().
+///
+/// The result of the event () is kept until the next event. Like in the original game,
+/// it's saved in the database (), so that it survives a restart of the server.
+/// Only one game server runs the event (), like the castle siege
+/// server of the original game. The other game servers take over its occupation state from the database, so that
+/// the benefits and penalties are the same on all of them.
+///
+public sealed class CrywolfContext : IEventStateProvider, IDisposable
+{
+ private static readonly TimeSpan AltarInfoInterval = TimeSpan.FromSeconds(2);
+ private static readonly TimeSpan BossMonsterInfoInterval = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan DataSynchronizationInterval = TimeSpan.FromSeconds(5);
+
+ private readonly GameContext _gameContext;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _hiddenNpcs = new();
+ private readonly ConcurrentDictionary _groups = new();
+ private readonly ConcurrentDictionary _eventMonsters = new();
+ private readonly ConcurrentDictionary _removedMonsters = new();
+ private readonly ConcurrentDictionary _scores = new();
+ private readonly List _heroes = new();
+ private readonly byte? _serverId;
+
+ private CrywolfEventDefinition _definition;
+ private GameMap? _map;
+ private NonPlayerCharacter? _statue;
+ private Monster? _balgass;
+ private string? _balgassKillerName;
+ private DateTime _stateStart = DateTime.UtcNow;
+ private DateTime _lastNotification = DateTime.MinValue;
+ private DateTime _lastAltarInfo = DateTime.MinValue;
+ private DateTime _lastBossMonsterInfo = DateTime.MinValue;
+ private int _lastRemainingTimeStep = -1;
+ private int _contractedAltarCount;
+ private bool _isBalgassAppearanceDone;
+ private bool _isStatueAttackStarted;
+ private bool _isStartForced;
+ private int _isSkipRequested;
+ private IReadOnlyList _altars = [];
+ private bool _isMissingConfigurationLogged;
+ private int _isBalgassDead;
+ private Guid? _dataId;
+ private bool _hasBattleResult;
+ private bool _isEventServer = true;
+ private bool _isEventServerLogged;
+ private DateTime _lastDataSynchronization = DateTime.MinValue;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The game context.
+ /// The definition of the event.
+ public CrywolfContext(GameContext gameContext, CrywolfEventDefinition definition)
+ : this(gameContext, definition, (gameContext as IGameServerContext)?.Id)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The game context.
+ /// The definition of the event.
+ /// The id of the game server, or if the game context isn't one of a game server.
+ internal CrywolfContext(GameContext gameContext, CrywolfEventDefinition definition, byte? serverId)
+ {
+ this._gameContext = gameContext;
+ this._definition = definition;
+ this._serverId = serverId;
+ this._logger = gameContext.LoggerFactory.CreateLogger();
+ }
+
+ ///
+ /// Gets the current state of the event.
+ ///
+ public CrywolfState State { get; private set; } = CrywolfState.None;
+
+ ///
+ /// Gets the occupation state of the fortress.
+ ///
+ public CrywolfOccupationState Occupation { get; private set; } = CrywolfOccupationState.Peace;
+
+ ///
+ /// Gets the stage of the army: 0, when it doesn't move, 1 when it advances, and 2 when it attacks the statue.
+ ///
+ public CrywolfArmyStage ArmyStage => this.State != CrywolfState.Start
+ ? CrywolfArmyStage.Waiting
+ : this._isStatueAttackStarted ? CrywolfArmyStage.AttackingStatue : CrywolfArmyStage.Advancing;
+
+ ///
+ /// Gets the altars.
+ ///
+ public IReadOnlyList Altars => this._altars;
+
+ ///
+ /// Gets the definition of the event.
+ ///
+ public CrywolfEventDefinition Definition => this._definition;
+
+ ///
+ /// Gets a value indicating whether this game server runs the event. Otherwise, it takes over the
+ /// occupation state of the game server which runs it.
+ ///
+ public bool IsEventServer => this._isEventServer;
+
+ ///
+ /// Gets a value indicating whether the benefits apply, because the fortress is in peace after it has been defended.
+ ///
+ public bool AreBenefitsApplied => this._definition.IsBenefitActive && this._hasBattleResult && this.Occupation == CrywolfOccupationState.Peace;
+
+ ///
+ /// Gets a value indicating whether the penalties apply, because the fortress is occupied.
+ ///
+ public bool ArePenaltiesApplied => this._definition.IsPenaltyActive && this.Occupation == CrywolfOccupationState.Occupied;
+
+ ///
+ /// Gets the multiplier of the maximum health of the monsters.
+ ///
+ public float MonsterHealthMultiplier => this.AreBenefitsApplied ? this._definition.MonsterHealthBenefitPercentage / 100f : 1f;
+
+ ///
+ /// Gets the multiplier of the experience of killed monsters.
+ ///
+ public float ExperienceMultiplier => this.ArePenaltiesApplied ? this._definition.ExperiencePenaltyPercentage / 100f : 1f;
+
+ ///
+ /// Gets the additional success rate in percent of the chaos machine mixes of the event tickets, which is also shown to the player.
+ ///
+ public byte ChaosRateBenefit => this.AreBenefitsApplied ? this._definition.ChaosRateBenefit : (byte)0;
+
+ ///
+ public bool IsEventRunning => this.State is >= CrywolfState.Notify2 and <= CrywolfState.End;
+
+ ///
+ /// Gets a value indicating whether the battle is running.
+ ///
+ public bool IsBattleRunning => this.State == CrywolfState.Start;
+
+ ///
+ /// Gets the remaining time of the current state.
+ ///
+ public TimeSpan RemainingTime
+ {
+ get
+ {
+ var remaining = this._definition.GetDuration(this.State) - (DateTime.UtcNow - this._stateStart);
+ return remaining > TimeSpan.Zero ? remaining : TimeSpan.Zero;
+ }
+ }
+
+ ///
+ /// Gets the crywolf map.
+ ///
+ internal GameMap? Map => this._map;
+
+ private bool IsBalgassDead => Volatile.Read(ref this._isBalgassDead) != 0;
+
+ ///
+ public bool IsSpawnWaveActive(byte waveNumber) => false;
+
+ ///
+ /// Initializes the context by loading the persistent state and registering at the map of the event.
+ ///
+ public async ValueTask InitializeAsync()
+ {
+ this.UpdateEventServer();
+ if (await this.LoadDataAsync().ConfigureAwait(false) && this._isEventServer)
+ {
+ // The server was stopped during the war. Like in the original game, the event starts
+ // again at its next start time, and the result of the last battle stays.
+ await this.SaveDataAsync(false).ConfigureAwait(false);
+ }
+
+ this._lastDataSynchronization = DateTime.UtcNow;
+
+ // The occupation state applies to game servers without the crywolf map, too.
+ this._map = await this._gameContext.GetMapAsync((ushort)this._definition.MapNumber).ConfigureAwait(false);
+ if (this._map is null)
+ {
+ if (this._isEventServer)
+ {
+ this._logger.LogWarning("The map {map} of the crywolf event wasn't found.", this._definition.MapNumber);
+ }
+
+ return;
+ }
+
+ if (this._isEventServer
+ && this._definition.MonsterSkills.Any(skill => skill.StunChance > 0)
+ && this._gameContext.Configuration.MagicEffects.All(effect => effect.Number != CrywolfMonsterIntelligence.StunnedMagicEffectNumber))
+ {
+ this._logger.LogWarning(
+ "The magic effect {effect} of the stun is missing in the configuration, so the monsters of the crywolf event can't stun. It's added by the data update {update}.",
+ CrywolfMonsterIntelligence.StunnedMagicEffectNumber,
+ "Fix summoner curse skills");
+ }
+
+ this._map.ObjectAdded += this.OnObjectAddedToMapAsync;
+ this.InitializeNpcs();
+ await this.ApplyOccupationAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Executes one step of the event. It's called every second.
+ ///
+ public async ValueTask TickAsync()
+ {
+ if (this.State == CrywolfState.None && this.UpdateEventServer())
+ {
+ // This game server took over the event, e.g. because the configuration changed. A war of the
+ // previous game server is reset, like after a restart.
+ if (await this.LoadDataAsync().ConfigureAwait(false))
+ {
+ await this.SaveDataAsync(false).ConfigureAwait(false);
+ }
+
+ await this.ApplyOccupationAsync().ConfigureAwait(false);
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ }
+
+ if (this._map is not null && this._altars.Count == 0)
+ {
+ this.InitializeNpcs();
+ }
+
+ if (!this._isEventServer)
+ {
+ Interlocked.Exchange(ref this._isSkipRequested, 0);
+ await this.SynchronizeWithEventServerAsync().ConfigureAwait(false);
+
+ // The hidden NPCs have to be shown as hidden to the players which see them for the first time.
+ await this.UpdateEffectsAsync().ConfigureAwait(false);
+ return;
+ }
+
+ if (this._map is null)
+ {
+ return;
+ }
+
+ if (this._altars.Count != this._definition.AltarNumbers.Count || this._statue is null)
+ {
+ // Without the altars and the statue (e.g. when the data update wasn't applied yet), the event can't run.
+ if (!this._isMissingConfigurationLogged)
+ {
+ this._isMissingConfigurationLogged = true;
+ this._logger.LogWarning("The crywolf event doesn't run, because the statue and the altars aren't spawned on the map {map}.", this._definition.MapNumber);
+ }
+
+ return;
+ }
+
+ if (Interlocked.Exchange(ref this._isSkipRequested, 0) != 0)
+ {
+ this.Skip();
+ }
+
+ var elapsed = DateTime.UtcNow - this._stateStart;
+ switch (this.State)
+ {
+ case CrywolfState.None:
+ await this.CheckStartAsync().ConfigureAwait(false);
+ break;
+ case CrywolfState.Notify1:
+ await this.NotifyAsync(player => player.GetLocalizedMessage(nameof(PlayerMessage.CrywolfNotify1)), true).ConfigureAwait(false);
+ await this.ProceedAfterDurationAsync(elapsed, CrywolfState.Notify2).ConfigureAwait(false);
+ break;
+ case CrywolfState.Notify2:
+ await this.RemoveCommonMonstersAsync().ConfigureAwait(false);
+ await this.NotifyRemainingTimeAsync(nameof(PlayerMessage.CrywolfContractsStartInMinutes), nameof(PlayerMessage.CrywolfContractsStartInSeconds)).ConfigureAwait(false);
+ await this.ProceedAfterDurationAsync(elapsed, CrywolfState.Ready).ConfigureAwait(false);
+ break;
+ case CrywolfState.Ready:
+ await this.RemoveCommonMonstersAsync().ConfigureAwait(false);
+ await this.UpdateAltarsAsync().ConfigureAwait(false);
+ await this.NotifyRemainingTimeAsync(nameof(PlayerMessage.CrywolfAttackStartsInMinutes), nameof(PlayerMessage.CrywolfAttackStartsInSeconds)).ConfigureAwait(false);
+ await this.ProceedAfterDurationAsync(elapsed, CrywolfState.Start).ConfigureAwait(false);
+ break;
+ case CrywolfState.Start:
+ await this.RemoveCommonMonstersAsync().ConfigureAwait(false);
+ await this.UpdateBattleAsync(elapsed).ConfigureAwait(false);
+ break;
+ case CrywolfState.End:
+ await this.ProceedAfterDurationAsync(elapsed, CrywolfState.EndCycle).ConfigureAwait(false);
+ break;
+ case CrywolfState.EndCycle:
+ await this.ProceedAfterDurationAsync(elapsed, CrywolfState.None).ConfigureAwait(false);
+ break;
+ default:
+ // nothing to do
+ break;
+ }
+
+ await this.UpdateEffectsAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates the definition of the event, e.g. after it has been changed in the admin panel.
+ ///
+ /// The definition.
+ public void UpdateDefinition(CrywolfEventDefinition definition)
+ {
+ this._definition = definition;
+ }
+
+ ///
+ /// Forces the event to proceed with the next step: it starts when it's not running, and during the battle
+ /// Balgass appears if he didn't yet. Otherwise, the current state ends.
+ ///
+ public void SkipWaitingTime()
+ {
+ // It's applied by the next step, so that it doesn't interfere with a running step.
+ Interlocked.Exchange(ref this._isSkipRequested, 1);
+ }
+
+ ///
+ /// Determines whether the NPC is hidden, because the fortress isn't in peace.
+ /// Players can't talk to hidden NPCs.
+ ///
+ /// The NPC.
+ /// true, if the NPC is hidden; otherwise, false.
+ public bool IsNpcHidden(NonPlayerCharacter npc)
+ {
+ return this._hiddenNpcs.TryGetValue(npc, out var display) && display.Effect == CrywolfEffect.NpcHidden;
+ }
+
+ ///
+ /// Determines whether the player loses experience when it's killed on the specified map.
+ /// During the battle, it doesn't lose experience on the crywolf map.
+ ///
+ /// The map.
+ /// true, if the player keeps its experience; otherwise, false.
+ public bool KeepsExperienceOnDeath(GameMap? map)
+ {
+ return this.IsBattleRunning && map is not null && map == this._map;
+ }
+
+ ///
+ /// Shows the current state of the event to a player, which entered the crywolf map.
+ ///
+ /// The player.
+ public async ValueTask ShowCurrentStateAsync(Player player)
+ {
+ if (player.CurrentMap != this._map)
+ {
+ // The client loads the terrain of the occupation state for its current map.
+ return;
+ }
+
+ await player.InvokeViewPlugInAsync(p => p.ShowStateAsync(this.Occupation, this.State)).ConfigureAwait(false);
+ if (!this._isEventServer && this.Occupation == CrywolfOccupationState.War)
+ {
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfBattleOnOtherServer)).ConfigureAwait(false);
+ }
+
+ if (this.State is CrywolfState.Ready or CrywolfState.Start)
+ {
+ var (shield, altarStates) = this.GetStatueAndAltarInfo();
+ await player.InvokeViewPlugInAsync(p => p.ShowStatueAndAltarsAsync(shield, altarStates)).ConfigureAwait(false);
+ }
+
+ if (this.State == CrywolfState.Start)
+ {
+ // The remaining time isn't sent here, because the client expects it every 20 seconds, and would count the seconds wrong.
+ var (balgassHealth, darkElfCount) = this.GetBossMonsterInfo();
+ await player.InvokeViewPlugInAsync(p => p.ShowBossMonsterInfoAsync(balgassHealth, darkElfCount)).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Tries to contract an altar with the player.
+ ///
+ /// The player.
+ /// The id of the altar, as the client knows it.
+ public async ValueTask ContractAltarAsync(Player player, ushort altarId)
+ {
+ var altar = this._altars.FirstOrDefault(a => a.Npc.Id == altarId);
+ if (altar is null)
+ {
+ return;
+ }
+
+ CrywolfContractResult result;
+ if (this.State is not (CrywolfState.Ready or CrywolfState.Start))
+ {
+ // Unlike the original game, the altars can only be contracted while the event is running.
+ result = CrywolfContractResult.NotAvailable;
+ }
+ else
+ {
+ lock (altar)
+ {
+ result = altar.TryStartContract(player, this._definition, DateTime.UtcNow);
+ }
+ }
+
+ var altarNumber = altar.Index + 1;
+ switch (result)
+ {
+ case CrywolfContractResult.AlreadyContracting:
+ // The client sent the request again, e.g. by a double click. Nothing changed.
+ break;
+ case CrywolfContractResult.Success:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractAttemptInfo), (int)this._definition.ContractDelay.TotalSeconds).ConfigureAwait(false);
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfContractAttempt), player.Name, altarNumber).ConfigureAwait(false);
+ break;
+ case CrywolfContractResult.Cooldown:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfAltarCooldown), altarNumber).ConfigureAwait(false);
+ break;
+ case CrywolfContractResult.WrongPosition:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractWrongPosition), altarNumber).ConfigureAwait(false);
+ break;
+ case CrywolfContractResult.Mounted:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractMounted)).ConfigureAwait(false);
+ break;
+ case CrywolfContractResult.NotAvailable when this.State is not (CrywolfState.Ready or CrywolfState.Start):
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfNotActive)).ConfigureAwait(false);
+ break;
+ case CrywolfContractResult.NotAvailable:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfAltarNotAvailable), altarNumber).ConfigureAwait(false);
+ break;
+ default:
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractRequirement), this._definition.MinimumContractLevel).ConfigureAwait(false);
+ break;
+ }
+
+ byte altarState;
+ lock (altar)
+ {
+ altarState = altar.GetClientState(this._definition.ContractsPerAltar);
+ }
+
+ var isSuccess = result is CrywolfContractResult.Success or CrywolfContractResult.AlreadyContracting;
+ await player.InvokeViewPlugInAsync(p => p.ShowContractResultAsync(isSuccess, altar.Index, altarState)).ConfigureAwait(false);
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (this._map is { } map)
+ {
+ map.ObjectAdded -= this.OnObjectAddedToMapAsync;
+ }
+ }
+
+ ///
+ /// Gets the leader of a group, if it's alive.
+ ///
+ /// The wave number of the group.
+ /// The leader of the group.
+ internal Monster? GetLeader(byte waveNumber)
+ {
+ return this._groups.TryGetValue(waveNumber, out var group) && group.Leader is { IsAlive: true } leader ? leader : null;
+ }
+
+ ///
+ /// Revives the dead members of a group, which is done by the leader of the group.
+ ///
+ /// The wave number of the group.
+ internal async ValueTask ReviveGroupMembersAsync(byte waveNumber)
+ {
+ if (!this._groups.TryGetValue(waveNumber, out var group) || this.State != CrywolfState.Start)
+ {
+ return;
+ }
+
+ // The leader revives one of the dead members at a time.
+ var now = DateTime.UtcNow;
+ if (now - group.LastRevive < this._definition.MemberReviveInterval
+ || group.GetDeadMembers().FirstOrDefault() is not { } spawnArea)
+ {
+ return;
+ }
+
+ group.LastRevive = now;
+ if (await this.SpawnMemberAsync(group, spawnArea).ConfigureAwait(false) is { } member
+ && group.Leader is { IsAlive: true } leader)
+ {
+ // Like in the original game, the client shows the revival as a skill of the leader.
+ var skillNumber = this._definition.ReviveSkillNumber;
+ await leader.ForEachWorldObserverAsync(p => p.ShowMonsterSkillAsync(leader, member, skillNumber), true).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Lets a ballista bombard the area around its target point.
+ ///
+ /// The monster of the ballista.
+ /// The definition of the ballista.
+ internal async ValueTask BombardAsync(Monster ballista, CrywolfBallista definition)
+ {
+ if (this._map is not { } map)
+ {
+ return;
+ }
+
+ var spread = this._definition.BallistaTargetSpread;
+ var target = new Point(
+ (byte)Math.Clamp(definition.TargetX + Rand.NextInt(-spread, spread + 1), 0, byte.MaxValue),
+ (byte)Math.Clamp(definition.TargetY + Rand.NextInt(-spread, spread + 1), 0, byte.MaxValue));
+ var players = map.GetAttackablesInRange(target, this._definition.BallistaEffectRadius).OfType().ToList();
+ foreach (var player in players)
+ {
+ await player.InvokeViewPlugInAsync(p => p.ShowBallistaAttackAsync(ballista, target)).ConfigureAwait(false);
+ }
+
+ foreach (var player in players.Where(p => p.IsActive() && !p.IsAtSafezone() && p.IsInRange(target, this._definition.BallistaHitRadius)))
+ {
+ await player.AttackByAsync(ballista, null, false).ConfigureAwait(false);
+ }
+ }
+
+ private static string GetRankName(int rank)
+ {
+ return rank switch
+ {
+ 4 => "S",
+ 3 => "A",
+ 2 => "B",
+ 1 => "C",
+ _ => "D",
+ };
+ }
+
+ ///
+ /// Lets the event proceed, like requested by .
+ ///
+ private void Skip()
+ {
+ if (this.State == CrywolfState.None)
+ {
+ this._isStartForced = true;
+ }
+ else if (this.State == CrywolfState.Start && !this._isBalgassAppearanceDone)
+ {
+ // The battle continues at the appearance of Balgass, so that the battle against him can be tested.
+ this._stateStart = DateTime.UtcNow - this._definition.BalgassAppearanceDelay;
+ }
+ else
+ {
+ this._stateStart = DateTime.MinValue;
+ }
+ }
+
+ private void InitializeNpcs()
+ {
+ if (this._map is not { } map)
+ {
+ return;
+ }
+
+ var npcs = map.GetNpcsInRange(new Point(128, 128), byte.MaxValue);
+ this._statue = npcs.FirstOrDefault(npc => npc.Definition.Number == this._definition.StatueNumber);
+ var altars = new List();
+ for (var i = 0; i < this._definition.AltarNumbers.Count; i++)
+ {
+ var number = this._definition.AltarNumbers[i];
+ if (npcs.FirstOrDefault(npc => npc.Definition.Number == number) is { } npc)
+ {
+ altars.Add(new CrywolfAltar(i, npc));
+ }
+ }
+
+ // The list is replaced as a whole, because it's read by the requests of the players.
+ this._altars = altars;
+
+ foreach (var npc in npcs.Where(this.IsCommonNpc))
+ {
+ this._hiddenNpcs.TryAdd(npc, new CrywolfEffectDisplay(npc));
+ }
+ }
+
+ private bool IsCommonNpc(NonPlayerCharacter npc)
+ {
+ return npc.Definition.ObjectKind != NpcObjectKind.Monster
+ && npc.SpawnArea.SpawnTrigger == SpawnTrigger.Automatic
+ && npc != this._statue
+ && !this._definition.AltarNumbers.Contains(npc.Definition.Number)
+ && !this._definition.AlwaysVisibleNpcNumbers.Contains(npc.Definition.Number);
+ }
+
+ private async ValueTask CheckStartAsync()
+ {
+ var isStartTime = this._definition.IsStartTime(TimeZoneInfo.ConvertTimeFromUtc(DateTime.UtcNow, this._gameContext.ServerTimeZone));
+ if (!isStartTime && !this._isStartForced)
+ {
+ return;
+ }
+
+ this._isStartForced = false;
+ await this.ChangeStateAsync(CrywolfState.Notify1).ConfigureAwait(false);
+ }
+
+ private async ValueTask ProceedAfterDurationAsync(TimeSpan elapsed, CrywolfState nextState)
+ {
+ if (elapsed >= this._definition.GetDuration(this.State))
+ {
+ await this.ChangeStateAsync(nextState).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask ChangeStateAsync(CrywolfState state)
+ {
+ this.State = state;
+ this._stateStart = DateTime.UtcNow;
+ this._lastNotification = DateTime.MinValue;
+ this._logger.LogInformation("Crywolf state changed to {state}.", state);
+ switch (state)
+ {
+ case CrywolfState.Notify2:
+ await this.StartWarAsync().ConfigureAwait(false);
+ break;
+ case CrywolfState.Ready:
+ await this.PrepareBattleAsync().ConfigureAwait(false);
+ break;
+ case CrywolfState.Start:
+ await this.StartBattleAsync().ConfigureAwait(false);
+ break;
+ case CrywolfState.End:
+ await this.EndBattleAsync().ConfigureAwait(false);
+ break;
+ case CrywolfState.EndCycle:
+ await this.EndCycleAsync().ConfigureAwait(false);
+ break;
+ default:
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ break;
+ }
+ }
+
+ private async ValueTask StartWarAsync()
+ {
+ await this.ShowMessageToAllPlayersAsync(nameof(PlayerMessage.CrywolfNotify2)).ConfigureAwait(false);
+ this.Occupation = CrywolfOccupationState.War;
+ await this.SaveDataAsync(false).ConfigureAwait(false);
+ await this.ApplyOccupationAsync().ConfigureAwait(false);
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ await this.RemoveCommonMonstersAsync().ConfigureAwait(false);
+ }
+
+ private async ValueTask PrepareBattleAsync()
+ {
+ await this.ShowMessageToAllPlayersAsync(nameof(PlayerMessage.CrywolfReady)).ConfigureAwait(false);
+ await this.RemoveEventMonstersAsync().ConfigureAwait(false);
+ Volatile.Write(ref this._isBalgassDead, 0);
+ this._balgassKillerName = null;
+ this._isBalgassAppearanceDone = false;
+ this._isStatueAttackStarted = false;
+ this._contractedAltarCount = 0;
+ this._scores.Clear();
+ foreach (var altar in this._altars)
+ {
+ lock (altar)
+ {
+ altar.Reset();
+ }
+ }
+
+ foreach (var group in this._definition.MonsterGroups)
+ {
+ await this.SpawnGroupAsync(group).ConfigureAwait(false);
+ }
+
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ }
+
+ private async ValueTask StartBattleAsync()
+ {
+ await this.ShowMessageToAllPlayersAsync(nameof(PlayerMessage.CrywolfStart)).ConfigureAwait(false);
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ this._lastAltarInfo = DateTime.MinValue;
+ this._lastBossMonsterInfo = DateTime.MinValue;
+ this._lastRemainingTimeStep = -1;
+ if (this.CountContractedAltars() == 0)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfNoAltarContracted)).ConfigureAwait(false);
+ this.Occupation = CrywolfOccupationState.Occupied;
+ await this.ChangeStateAsync(CrywolfState.End).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask UpdateBattleAsync(TimeSpan elapsed)
+ {
+ await this.UpdateAltarsAsync().ConfigureAwait(false);
+ if (this._balgass is not null && this.IsBalgassDead)
+ {
+ // Balgass is checked first, so that the battle is won when the last contract ended at the same time.
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBalgassDefeated)).ConfigureAwait(false);
+ this.Occupation = CrywolfOccupationState.Peace;
+ await this.ChangeStateAsync(CrywolfState.End).ConfigureAwait(false);
+ return;
+ }
+
+ if (this.CountContractedAltars() == 0)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfNoAltarContracted)).ConfigureAwait(false);
+ this.Occupation = CrywolfOccupationState.Occupied;
+ await this.ChangeStateAsync(CrywolfState.End).ConfigureAwait(false);
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ if (now - this._lastBossMonsterInfo >= BossMonsterInfoInterval)
+ {
+ this._lastBossMonsterInfo = now;
+ var (balgassHealth, darkElfCount) = this.GetBossMonsterInfo();
+ await this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowBossMonsterInfoAsync(balgassHealth, darkElfCount)).AsTask()).ConfigureAwait(false);
+ }
+
+ // The client counts down the seconds of a minute by itself, and expects the remaining time at each 20 seconds of it.
+ var remaining = this.RemainingTime;
+ var remainingTimeStep = (int)Math.Ceiling(remaining.TotalSeconds / 20);
+ if (remainingTimeStep != this._lastRemainingTimeStep)
+ {
+ this._lastRemainingTimeStep = remainingTimeStep;
+ await this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowRemainingTimeAsync(remaining)).AsTask()).ConfigureAwait(false);
+ }
+
+ if (!this._isStatueAttackStarted && elapsed >= this._definition.StatueAttackDelay)
+ {
+ this._isStatueAttackStarted = true;
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfStatueAttack)).ConfigureAwait(false);
+ }
+
+ if (!this._isBalgassAppearanceDone && elapsed >= this._definition.BalgassAppearanceDelay)
+ {
+ this._isBalgassAppearanceDone = true;
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBalgassAppeared)).ConfigureAwait(false);
+ await this.SpawnBalgassAsync().ConfigureAwait(false);
+ }
+
+ if (elapsed >= this._definition.BattleDuration)
+ {
+ if (this._balgass is not null)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBalgassAlive)).ConfigureAwait(false);
+ this.Occupation = CrywolfOccupationState.Occupied;
+ }
+ else
+ {
+ // Balgass didn't appear (e.g. because of the configuration), and the altars were protected.
+ this.Occupation = CrywolfOccupationState.Peace;
+ }
+
+ await this.ChangeStateAsync(CrywolfState.End).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask EndBattleAsync()
+ {
+ var isDefended = this.Occupation == CrywolfOccupationState.Peace;
+ this._logger.LogInformation("Crywolf battle ended, defended: {defended}.", isDefended);
+ await this.ShowMessageToAllPlayersAsync(isDefended ? nameof(PlayerMessage.CrywolfDefenseSucceeded) : nameof(PlayerMessage.CrywolfDefenseFailed)).ConfigureAwait(false);
+
+ // The client only shows the result, when the occupation is final.
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+
+ foreach (var altar in this._altars)
+ {
+ Player? contractor;
+ lock (altar)
+ {
+ // An elf which is still attempting the contract doesn't get the score and the reward.
+ contractor = altar.State == CrywolfAltarState.Contracted ? altar.Contractor : null;
+ altar.Reset();
+ altar.Hide();
+ }
+
+ if (contractor is null)
+ {
+ continue;
+ }
+
+ this._scores.AddOrUpdate(contractor, this._definition.ContractorScore, (_, score) => score + this._definition.ContractorScore);
+ if (isDefended)
+ {
+ await this.DropRewardAsync(contractor).ConfigureAwait(false);
+ }
+ }
+
+ await this.ForEachMapPlayerAsync(player => this.RewardExperienceAsync(player, isDefended).AsTask()).ConfigureAwait(false);
+
+ // Unlike the original game, the heroes are shown already at the end of the battle. The client shows
+ // them in the result dialog, which it opens after an animation of some frames, so with a higher frame
+ // rate than the original client, the heroes wouldn't arrive in time at the end of the event.
+ var heroes = this._scores
+ .Where(entry => entry.Key.CurrentMap == this._map)
+ .OrderByDescending(entry => entry.Value)
+ .Take(Math.Min(this._definition.HeroCount, 5))
+ .ToList();
+ this._heroes.Clear();
+ this._heroes.AddRange(heroes.Select(entry => entry.Key));
+ var heroList = heroes
+ .Select(entry => new CrywolfHero(entry.Key.Name, entry.Value, entry.Key.SelectedCharacter?.CharacterClass?.Number ?? 0))
+ .ToList();
+ await this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowHeroListAsync(heroList)).AsTask()).ConfigureAwait(false);
+ await this.SaveDataAsync(true).ConfigureAwait(false);
+ }
+
+ private async ValueTask EndCycleAsync()
+ {
+ await this.RemoveEventMonstersAsync().ConfigureAwait(false);
+ await this.ApplyOccupationAsync().ConfigureAwait(false);
+ await this.RestoreCommonMonstersAsync().ConfigureAwait(false);
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+
+ if (this.Occupation == CrywolfOccupationState.Peace)
+ {
+ // Like in the original game, the heroes get their reward at the end of the event.
+ foreach (var hero in this._heroes)
+ {
+ await this.DropRewardAsync(hero).ConfigureAwait(false);
+ }
+ }
+
+ this._heroes.Clear();
+ this._scores.Clear();
+ }
+
+ private async ValueTask RewardExperienceAsync(Player player, bool isDefended)
+ {
+ var score = this._scores.GetValueOrDefault(player);
+ var rank = this._definition.GetRank(score);
+ var experience = this._definition.GetExperience(rank, isDefended);
+ if (experience > 0)
+ {
+ await player.AddExperienceAsync(experience, null).ConfigureAwait(false);
+ }
+
+ await player.InvokeViewPlugInAsync(p => p.ShowPersonalRankAsync(rank, experience)).ConfigureAwait(false);
+ await player.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfRankResult), GetRankName(rank), experience).ConfigureAwait(false);
+ }
+
+ private async ValueTask DropRewardAsync(Player player)
+ {
+ if (player.CurrentMap is not { } map
+ || this._gameContext.Configuration.Items.FirstOrDefault(item => item.Group == this._definition.RewardItemGroup && item.Number == this._definition.RewardItemNumber) is not { } definition)
+ {
+ return;
+ }
+
+ var item = new TemporaryItem { Definition = definition, Durability = 1 };
+ await map.AddAsync(new DroppedItem(item, player.Position, map, null, player.GetAsEnumerable())).ConfigureAwait(false);
+ }
+
+ private async ValueTask UpdateAltarsAsync()
+ {
+ var now = DateTime.UtcNow;
+ foreach (var altar in this._altars)
+ {
+ Player? contractor;
+ CrywolfContractChange change;
+ lock (altar)
+ {
+ contractor = altar.Contractor;
+ change = altar.Update(this._definition, now);
+ }
+
+ if (contractor is null)
+ {
+ continue;
+ }
+
+ var altarNumber = altar.Index + 1;
+ if (change == CrywolfContractChange.Validated)
+ {
+ await contractor.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractValid), altarNumber).ConfigureAwait(false);
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfContractValidToOthers), contractor.Name, altarNumber).ConfigureAwait(false);
+ }
+ else if (change == CrywolfContractChange.Cancelled)
+ {
+ await contractor.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfContractCancelled), altarNumber).ConfigureAwait(false);
+ }
+ else
+ {
+ // nothing changed
+ }
+ }
+
+ var contractedCount = this.CountContractedAltars();
+ var previousCount = Interlocked.Exchange(ref this._contractedAltarCount, contractedCount);
+ if (contractedCount != previousCount)
+ {
+ var (shieldPercentage, _) = this.GetStatueAndAltarInfo();
+ if (contractedCount == 0)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBarrierDisappeared)).ConfigureAwait(false);
+ }
+ else if (previousCount == 0)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBarrierCreated)).ConfigureAwait(false);
+ }
+ else
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBarrierStatus), contractedCount, shieldPercentage).ConfigureAwait(false);
+ }
+ }
+
+ if (now - this._lastAltarInfo >= AltarInfoInterval)
+ {
+ this._lastAltarInfo = now;
+ var (shield, altarStates) = this.GetStatueAndAltarInfo();
+ await this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowStatueAndAltarsAsync(shield, altarStates)).AsTask()).ConfigureAwait(false);
+ }
+ }
+
+ private int CountContractedAltars()
+ {
+ return this._altars.Count(altar => altar.State == CrywolfAltarState.Contracted);
+ }
+
+ ///
+ /// Gets the shield of the statue in percent, which is the health of the elves of the contracted altars, and the states of the altars.
+ ///
+ private (int ShieldPercentage, IReadOnlyList AltarStates) GetStatueAndAltarInfo()
+ {
+ double health = 0;
+ double maximumHealth = 0;
+ var altarStates = new List();
+ foreach (var altar in this._altars)
+ {
+ Player? contractor;
+ lock (altar)
+ {
+ contractor = altar.State == CrywolfAltarState.Contracted ? altar.Contractor : null;
+ altarStates.Add(altar.GetClientState(this._definition.ContractsPerAltar));
+ }
+
+ if (contractor?.Attributes is { } attributes)
+ {
+ health += attributes[Stats.CurrentHealth];
+ maximumHealth += attributes[Stats.MaximumHealth];
+ }
+ }
+
+ var percentage = maximumHealth > 0 ? (int)Math.Clamp(health * 100 / maximumHealth, 0, 100) : 0;
+ return (percentage, altarStates);
+ }
+
+ private (int BalgassHealthPercentage, int DarkElfCount) GetBossMonsterInfo()
+ {
+ var balgassHealth = -1;
+ if (this._balgass is { IsAlive: true } balgass)
+ {
+ var maximumHealth = balgass.Attributes[Stats.MaximumHealth];
+ balgassHealth = maximumHealth > 0 ? (int)Math.Clamp(balgass.Health * 100 / maximumHealth, 1, 100) : 1;
+ }
+
+ var darkElfCount = this._groups.Values.Count(group => group.Leader is { IsAlive: true });
+ return (balgassHealth, darkElfCount);
+ }
+
+ private async ValueTask NotifyAsync(Func getMessage, bool global)
+ {
+ var now = DateTime.UtcNow;
+ if (now - this._lastNotification < this._definition.NotificationInterval)
+ {
+ return;
+ }
+
+ this._lastNotification = now;
+ if (global)
+ {
+ await this.ShowMessageToAllPlayersAsync(getMessage).ConfigureAwait(false);
+ }
+ else
+ {
+ await this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(getMessage(player), MessageType.GoldenCenter)).AsTask()).ConfigureAwait(false);
+ }
+ }
+
+ private ValueTask NotifyRemainingTimeAsync(string minutesMessageKey, string secondsMessageKey)
+ {
+ var remaining = this.RemainingTime;
+ return this.NotifyAsync(
+ player => remaining >= TimeSpan.FromMinutes(1)
+ ? player.GetLocalizedMessage(minutesMessageKey, (int)Math.Ceiling(remaining.TotalMinutes))
+ : player.GetLocalizedMessage(secondsMessageKey, (int)Math.Ceiling(remaining.TotalSeconds)),
+ true);
+ }
+
+ private async ValueTask ApplyOccupationAsync()
+ {
+ if (this._map is not { } map)
+ {
+ return;
+ }
+
+ // Like the client, which loads the terrain of the occupation state by itself, the server uses the terrain
+ // variant of the map with the number of the occupation state. In peace, it's the normal terrain.
+ // The crywolf map has no other changes of the terrain at runtime, so it's just replaced.
+ var terrainVariant = this.Occupation == CrywolfOccupationState.Peace
+ ? null
+ : map.Definition.TerrainVariants.FirstOrDefault(variant => variant.Number == (short)this.Occupation);
+ if (terrainVariant is null && this.Occupation != CrywolfOccupationState.Peace)
+ {
+ this._logger.LogWarning("The terrain of the crywolf map for the occupation state {occupation} is missing.", this.Occupation);
+ }
+
+ map.Terrain.LoadTerrainData(terrainVariant?.TerrainData ?? map.Definition.TerrainData);
+ foreach (var display in this._hiddenNpcs.Values)
+ {
+ display.Effect = this.Occupation == CrywolfOccupationState.Peace ? null : CrywolfEffect.NpcHidden;
+ }
+
+ await this.UpdateEffectsAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Updates whether this game server runs the event, according to the definition.
+ ///
+ /// true, if this game server took over the event from another game server; otherwise, false.
+ private bool UpdateEventServer()
+ {
+ var isEventServer = this._serverId is not { } serverId || serverId == this._definition.GameServerId;
+ if (isEventServer == this._isEventServer && this._isEventServerLogged)
+ {
+ return false;
+ }
+
+ var isTakeOver = isEventServer && !this._isEventServer && this._isEventServerLogged;
+ this._isEventServer = isEventServer;
+ this._isEventServerLogged = true;
+ if (isEventServer)
+ {
+ this._logger.LogInformation("This game server runs the crywolf event.");
+ }
+ else
+ {
+ this._logger.LogInformation("The crywolf event runs on the game server {serverId}. This game server takes over its occupation state.", this._definition.GameServerId);
+ }
+
+ return isTakeOver;
+ }
+
+ private async ValueTask SynchronizeWithEventServerAsync()
+ {
+ var now = DateTime.UtcNow;
+ if (now - this._lastDataSynchronization < DataSynchronizationInterval)
+ {
+ return;
+ }
+
+ this._lastDataSynchronization = now;
+ var previousOccupation = this.Occupation;
+ await this.LoadDataAsync().ConfigureAwait(false);
+ if (this.Occupation == previousOccupation)
+ {
+ return;
+ }
+
+ await this.ApplyOccupationAsync().ConfigureAwait(false);
+ await this.ShowStateToMapPlayersAsync().ConfigureAwait(false);
+ if (this.Occupation == CrywolfOccupationState.War)
+ {
+ await this.ShowMessageToAllPlayersAsync(nameof(PlayerMessage.CrywolfBattleOnOtherServer)).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Loads the persistent state of the event.
+ ///
+ /// true, if the war is running according to the persistent state; otherwise, false.
+ private async ValueTask LoadDataAsync()
+ {
+ try
+ {
+ using var context = this._gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CrywolfData), false, this._gameContext.Configuration);
+ if ((await context.GetAsync().ConfigureAwait(false)).FirstOrDefault() is not { } data)
+ {
+ return false;
+ }
+
+ this._dataId = data.Id;
+ this._hasBattleResult = data.LastBattleEnd.HasValue;
+ this.Occupation = data.IsWarRunning && !this._isEventServer && !this.IsWarOverdue(data)
+ ? CrywolfOccupationState.War
+ : data.IsOccupied ? CrywolfOccupationState.Occupied : CrywolfOccupationState.Peace;
+ return data.IsWarRunning;
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't load the occupation state of the crywolf fortress.");
+ return false;
+ }
+ }
+
+ ///
+ /// Determines whether the war should have ended already, because the longest possible war is over.
+ /// It happens when the game server which runs the event stopped during the war, or doesn't exist.
+ ///
+ /// The persistent state of the event.
+ /// true, if the war should have ended already; otherwise, false.
+ private bool IsWarOverdue(CrywolfData data)
+ {
+ var definition = this._definition;
+ var longestWar = definition.Notify2Duration + definition.ReadyDuration + definition.BattleDuration + definition.EndDuration;
+ return data.WarStart is { } warStart && DateTime.UtcNow - warStart > longestWar;
+ }
+
+ ///
+ /// Saves the persistent state of the event, so that it survives a restart and the other game servers take it over.
+ ///
+ /// If set to true, the battle just ended.
+ private async ValueTask SaveDataAsync(bool isBattleEnd)
+ {
+ try
+ {
+ using var context = this._gameContext.PersistenceContextProvider.CreateNewTypedContext(typeof(CrywolfData), false, this._gameContext.Configuration);
+ var data = this._dataId is { } id
+ ? await context.GetByIdAsync(id).ConfigureAwait(false)
+ : (await context.GetAsync().ConfigureAwait(false)).FirstOrDefault();
+ data ??= context.CreateNew();
+ var isWarStarting = this.Occupation == CrywolfOccupationState.War && !data.IsWarRunning;
+ data.IsWarRunning = this.Occupation == CrywolfOccupationState.War;
+ data.WarStart = data.IsWarRunning ? (isWarStarting ? DateTime.UtcNow : data.WarStart) : null;
+ if (!data.IsWarRunning)
+ {
+ data.IsOccupied = this.Occupation == CrywolfOccupationState.Occupied;
+ }
+
+ if (isBattleEnd)
+ {
+ data.LastBattleEnd = DateTime.UtcNow;
+ }
+
+ await context.SaveChangesAsync().ConfigureAwait(false);
+ this._dataId = data.Id;
+ this._hasBattleResult |= isBattleEnd;
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't save the occupation state of the crywolf fortress.");
+ }
+ }
+
+ private async ValueTask UpdateEffectsAsync()
+ {
+ foreach (var altar in this._altars)
+ {
+ await altar.Display.UpdateAsync().ConfigureAwait(false);
+ }
+
+ foreach (var display in this._hiddenNpcs.Values)
+ {
+ await display.UpdateAsync().ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask SpawnGroupAsync(CrywolfMonsterGroup definition)
+ {
+ var group = new CrywolfMonsterGroupState(definition, this.GetWaveSpawns(definition.WaveNumber).ToList());
+ this._groups[definition.WaveNumber] = group;
+ foreach (var spawnArea in group.SpawnAreas)
+ {
+ await this.SpawnMemberAsync(group, spawnArea).ConfigureAwait(false);
+ }
+ }
+
+ private async ValueTask SpawnMemberAsync(CrywolfMonsterGroupState group, MonsterSpawnArea spawnArea)
+ {
+ var monsterDefinition = spawnArea.MonsterDefinition!;
+ var ballista = this._definition.Ballistas.FirstOrDefault(b => b.X == spawnArea.X1 && b.Y == spawnArea.Y1);
+ var role = monsterDefinition.Number == this._definition.DarkElfNumber
+ ? CrywolfMonsterRole.Leader
+ : ballista is not null ? CrywolfMonsterRole.Ballista : CrywolfMonsterRole.Soldier;
+ var intelligence = new CrywolfMonsterIntelligence(this, role, group.Definition, ballista, this._logger);
+ if (await this.SpawnMonsterAsync(spawnArea, intelligence).ConfigureAwait(false) is { } monster)
+ {
+ group.SetMonster(spawnArea, monster, role == CrywolfMonsterRole.Leader);
+ return monster;
+ }
+
+ return null;
+ }
+
+ private async ValueTask SpawnBalgassAsync()
+ {
+ var spawnArea = this.GetWaveSpawns(this._definition.BalgassWaveNumber).FirstOrDefault();
+ if (spawnArea is null)
+ {
+ this._logger.LogWarning("The spawn of Balgass isn't configured at the wave {wave} of the crywolf map.", this._definition.BalgassWaveNumber);
+ return;
+ }
+
+ var intelligence = new CrywolfMonsterIntelligence(this, CrywolfMonsterRole.Balgass, null, null, this._logger);
+ if (await this.SpawnMonsterAsync(spawnArea, intelligence).ConfigureAwait(false) is { } balgass)
+ {
+ this._balgass = balgass;
+ }
+ }
+
+ private IEnumerable GetWaveSpawns(byte waveNumber)
+ {
+ return this._map?.Definition.MonsterSpawns
+ .Where(spawn => spawn.MonsterDefinition is not null
+ && spawn.SpawnTrigger == SpawnTrigger.OnceAtWaveStart
+ && spawn.WaveNumber == waveNumber)
+ ?? [];
+ }
+
+ private async ValueTask SpawnMonsterAsync(MonsterSpawnArea spawnArea, INpcIntelligence intelligence)
+ {
+ var map = this._map!;
+ var monster = new Monster(spawnArea, spawnArea.MonsterDefinition!, map, this._gameContext.DropGenerator, intelligence, this._gameContext.PlugInManager, this._gameContext.PathFinderPool, this);
+ try
+ {
+ monster.Initialize();
+ await map.AddAsync(monster).ConfigureAwait(false);
+ monster.OnSpawn();
+
+ // The monster only starts its intelligence when the first player observes it, but the army acts without observers.
+ intelligence.Start();
+ this._eventMonsters.TryAdd(monster, 0);
+ monster.Died += this.OnEventMonsterDied;
+ return monster;
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't spawn {monster} of the crywolf event.", spawnArea.MonsterDefinition);
+ monster.Dispose();
+ return null;
+ }
+ }
+
+ private async ValueTask RemoveEventMonstersAsync()
+ {
+ this._balgass = null;
+ this._groups.Clear();
+ foreach (var monster in this._eventMonsters.Keys)
+ {
+ monster.Died -= this.OnEventMonsterDied;
+ if (monster.IsAlive)
+ {
+ await monster.CurrentMap.RemoveAsync(monster).ConfigureAwait(false);
+ monster.Dispose();
+ }
+ }
+
+ this._eventMonsters.Clear();
+ }
+
+ ///
+ /// Removes the common monsters of the map during the war, like the original game does.
+ /// It's done in each step, because dead monsters respawn after some time.
+ ///
+ private async ValueTask RemoveCommonMonstersAsync()
+ {
+ if (this._map is not { } map)
+ {
+ return;
+ }
+
+ var commonMonsters = map.GetNpcsInRange(new Point(128, 128), byte.MaxValue)
+ .OfType()
+ .Where(monster => monster.Definition.ObjectKind == NpcObjectKind.Monster
+ && monster.SpawnArea.SpawnTrigger == SpawnTrigger.Automatic
+ && monster.IsAlive)
+ .ToList();
+ foreach (var monster in commonMonsters)
+ {
+ await map.RemoveAsync(monster).ConfigureAwait(false);
+ this._removedMonsters.TryAdd(monster, 0);
+ }
+ }
+
+ private async ValueTask RestoreCommonMonstersAsync()
+ {
+ if (this._map is not { } map)
+ {
+ return;
+ }
+
+ foreach (var monster in this._removedMonsters.Keys)
+ {
+ try
+ {
+ monster.Initialize();
+ await map.AddAsync(monster).ConfigureAwait(false);
+ monster.OnSpawn();
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't restore {monster} after the crywolf event.", monster);
+ }
+ }
+
+ this._removedMonsters.Clear();
+ }
+
+ private void OnEventMonsterDied(object? sender, DeathInformation e)
+ {
+ if (sender is not Monster monster)
+ {
+ return;
+ }
+
+ monster.Died -= this.OnEventMonsterDied;
+ this._eventMonsters.TryRemove(monster, out _);
+ if (monster == this._balgass)
+ {
+ this._balgassKillerName = e.KillerName;
+ Volatile.Write(ref this._isBalgassDead, 1);
+ }
+
+ _ = this.OnEventMonsterDiedAsync(monster, e);
+ }
+
+ private async Task OnEventMonsterDiedAsync(Monster monster, DeathInformation e)
+ {
+ try
+ {
+ if (this.State != CrywolfState.Start)
+ {
+ return;
+ }
+
+ var killer = this._map?.GetObject(e.KillerId) as Player;
+ if (monster == this._balgass)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfBalgassKilled), this._balgassKillerName ?? string.Empty).ConfigureAwait(false);
+ }
+ else if (monster.Definition.Number == this._definition.DarkElfNumber)
+ {
+ await this.ShowMessageToMapPlayersAsync(nameof(PlayerMessage.CrywolfDarkElfKilled), monster.SpawnArea.WaveNumber, e.KillerName).ConfigureAwait(false);
+ }
+ else
+ {
+ // no message for the other monsters
+ }
+
+ var score = this._definition.GetScore(monster.Definition.Number);
+ if (killer is not null && score > 0)
+ {
+ var newScore = this._scores.AddOrUpdate(killer, score, (_, current) => current + score);
+ await killer.ShowLocalizedBlueMessageAsync(nameof(PlayerMessage.CrywolfCurrentScore), newScore).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Unexpected error when {monster} of the crywolf event died.", monster);
+ }
+ }
+
+ private async ValueTask OnObjectAddedToMapAsync((GameMap Map, ILocateable Object) args)
+ {
+ if (args.Object is Player player)
+ {
+ await this.ShowCurrentStateAsync(player).ConfigureAwait(false);
+ }
+ }
+
+ private ValueTask ShowStateToMapPlayersAsync()
+ {
+ var occupation = this.Occupation;
+ var state = this.State;
+ return this.ForEachMapPlayerAsync(player => player.InvokeViewPlugInAsync(p => p.ShowStateAsync(occupation, state)).AsTask());
+ }
+
+ ///
+ /// Shows a golden message to all players of the game server, like the original game does.
+ ///
+ private ValueTask ShowMessageToAllPlayersAsync(string messageKey)
+ {
+ return this.ShowMessageToAllPlayersAsync(player => player.GetLocalizedMessage(messageKey));
+ }
+
+ private async ValueTask ShowMessageToAllPlayersAsync(Func getMessage)
+ {
+ var players = await this._gameContext.GetPlayersAsync().ConfigureAwait(false);
+ foreach (var player in players)
+ {
+ try
+ {
+ var message = getMessage(player);
+ await player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't notify {player} about the crywolf event.", player);
+ }
+ }
+ }
+
+ ///
+ /// Shows a golden message to the players of the crywolf map.
+ ///
+ private ValueTask ShowMessageToMapPlayersAsync(string messageKey, params object?[] arguments)
+ {
+ return this.ForEachMapPlayerAsync(player =>
+ {
+ var message = player.GetLocalizedMessage(messageKey, arguments);
+ return player.InvokeViewPlugInAsync(p => p.ShowMessageAsync(message, MessageType.GoldenCenter)).AsTask();
+ });
+ }
+
+ private async ValueTask ForEachMapPlayerAsync(Func action)
+ {
+ var players = await this._gameContext.GetPlayersAsync().ConfigureAwait(false);
+ foreach (var player in players.Where(player => player.CurrentMap is { } map && map == this._map))
+ {
+ try
+ {
+ await action(player).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Couldn't notify {player} about the crywolf event.", player);
+ }
+ }
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfContractChange.cs b/src/GameLogic/Crywolf/CrywolfContractChange.cs
new file mode 100644
index 0000000000..3abaaba90f
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfContractChange.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The change of the contract of an altar of the crywolf event.
+///
+public enum CrywolfContractChange
+{
+ ///
+ /// The contract didn't change.
+ ///
+ None,
+
+ ///
+ /// The contract got valid.
+ ///
+ Validated,
+
+ ///
+ /// The contract has been cancelled.
+ ///
+ Cancelled,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfContractResult.cs b/src/GameLogic/Crywolf/CrywolfContractResult.cs
new file mode 100644
index 0000000000..0c7e7980b3
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfContractResult.cs
@@ -0,0 +1,46 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The result of a request to contract an altar of the crywolf event.
+///
+public enum CrywolfContractResult
+{
+ ///
+ /// The elf is trying to contract the altar. The contract gets valid when the elf stays at the altar.
+ ///
+ Success,
+
+ ///
+ /// The altar can't be contracted now, e.g. because it's contracted already or the event isn't in the right state.
+ ///
+ NotAvailable,
+
+ ///
+ /// The character can't contract an altar, because of its class or level.
+ ///
+ NotQualified,
+
+ ///
+ /// The altar can't be contracted yet, because its last contract ended a short time ago.
+ ///
+ Cooldown,
+
+ ///
+ /// The character doesn't stand at the altar.
+ ///
+ WrongPosition,
+
+ ///
+ /// The character rides a mount, which isn't allowed for a contract.
+ ///
+ Mounted,
+
+ ///
+ /// The character is already contracting the altar, e.g. because the client sent the request twice.
+ ///
+ AlreadyContracting,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfEffect.cs b/src/GameLogic/Crywolf/CrywolfEffect.cs
new file mode 100644
index 0000000000..4bd37e623c
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfEffect.cs
@@ -0,0 +1,36 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The effects of the crywolf event, which the client shows at objects, matching the values expected by the client.
+///
+public enum CrywolfEffect : byte
+{
+ ///
+ /// The altar can be contracted.
+ ///
+ AltarEnabled = 21,
+
+ ///
+ /// The altar can't be contracted anymore.
+ ///
+ AltarDisabled = 22,
+
+ ///
+ /// The altar is contracted.
+ ///
+ AltarContracted = 23,
+
+ ///
+ /// An elf is trying to contract the altar.
+ ///
+ AltarAttempt = 24,
+
+ ///
+ /// The NPC is hidden, because the fortress isn't in peace.
+ ///
+ NpcHidden = 27,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfEffectDisplay.cs b/src/GameLogic/Crywolf/CrywolfEffectDisplay.cs
new file mode 100644
index 0000000000..2d5975fc30
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfEffectDisplay.cs
@@ -0,0 +1,79 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using MUnique.OpenMU.GameLogic.NPC;
+
+///
+/// Shows an effect of the crywolf event at an NPC to the players which observe it.
+///
+///
+/// The effects are sent separately, because the packet which adds the NPCs to the scope of a player
+/// doesn't contain their effects. The effects which a player has seen are remembered, because the client
+/// removes all effects of the event from an object, when it gets an effect which the object already has.
+/// When the NPC gets out of the scope of the player, the client forgets its effects as well.
+///
+internal sealed class CrywolfEffectDisplay
+{
+ private readonly Dictionary _shownEffects = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The NPC.
+ public CrywolfEffectDisplay(NonPlayerCharacter npc)
+ {
+ this.Npc = npc;
+ }
+
+ ///
+ /// Gets the NPC.
+ ///
+ public NonPlayerCharacter Npc { get; }
+
+ ///
+ /// Gets or sets the effect which should be shown, or null if no effect should be shown.
+ ///
+ public CrywolfEffect? Effect { get; set; }
+
+ ///
+ /// Shows the current effect to the players which observe the NPC and haven't seen it yet.
+ ///
+ public async ValueTask UpdateAsync()
+ {
+ List observers;
+ using (await this.Npc.ObserverLock.ReaderLockAsync())
+ {
+ observers = this.Npc.Observers.OfType().ToList();
+ }
+
+ foreach (var player in this._shownEffects.Keys.Except(observers).ToList())
+ {
+ this._shownEffects.Remove(player);
+ }
+
+ var effect = this.Effect;
+ foreach (var player in observers)
+ {
+ var isShown = this._shownEffects.TryGetValue(player, out var shownEffect);
+ if (isShown && shownEffect == effect)
+ {
+ continue;
+ }
+
+ if (isShown)
+ {
+ await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(this.Npc, shownEffect, false)).ConfigureAwait(false);
+ this._shownEffects.Remove(player);
+ }
+
+ if (effect is { } newEffect)
+ {
+ await player.InvokeViewPlugInAsync(p => p.ShowEffectAsync(this.Npc, newEffect, true)).ConfigureAwait(false);
+ this._shownEffects[player] = newEffect;
+ }
+ }
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfEventDefinition.cs b/src/GameLogic/Crywolf/CrywolfEventDefinition.cs
new file mode 100644
index 0000000000..8e55fe908e
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfEventDefinition.cs
@@ -0,0 +1,435 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// Describes the run of the crywolf event.
+///
+///
+/// It's configured at the , so it can be adapted in the admin panel.
+/// The monsters of the event are defined by the monster spawns of the crywolf map with the trigger
+/// and the wave numbers of the
+/// and . The default values are the ones of the original game.
+///
+public class CrywolfEventDefinition
+{
+ ///
+ /// Gets or sets the number of the crywolf map.
+ ///
+ public short MapNumber { get; set; } = 34;
+
+ ///
+ /// Gets or sets the id of the game server, which runs the event.
+ ///
+ ///
+ /// Like the castle siege server of the original game, only one game server runs the event. The other
+ /// game servers take over its result from the database, so that the occupation state and its benefits
+ /// and penalties are the same on all of them. On their crywolf map, the battle doesn't take place.
+ ///
+ public byte GameServerId { get; set; }
+
+ ///
+ /// Gets or sets the days of the week, on which the event starts.
+ ///
+ public IList StartDays { get; set; } = new List { DayOfWeek.Wednesday, DayOfWeek.Saturday };
+
+ ///
+ /// Gets or sets the times of the day (in the time zone of the server), at which the event starts with its first notification.
+ ///
+ public IList StartTimes { get; set; } = new List { new(20, 30) };
+
+ ///
+ /// Gets or sets the duration of the first notification of the upcoming attack.
+ ///
+ public TimeSpan Notify1Duration { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Gets or sets the duration of the second notification, in which the fortress prepares for the attack.
+ ///
+ public TimeSpan Notify2Duration { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Gets or sets the duration, in which the elves can contract the altars before the battle starts.
+ ///
+ public TimeSpan ReadyDuration { get; set; } = TimeSpan.FromSeconds(150);
+
+ ///
+ /// Gets or sets the duration of the battle.
+ ///
+ public TimeSpan BattleDuration { get; set; } = TimeSpan.FromMinutes(15);
+
+ ///
+ /// Gets or sets the duration, in which the result is shown.
+ ///
+ public TimeSpan EndDuration { get; set; } = TimeSpan.FromSeconds(15);
+
+ ///
+ /// Gets or sets the duration, after which the event is finished and the common monsters return.
+ ///
+ public TimeSpan EndCycleDuration { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets the interval, in which the notifications are repeated.
+ ///
+ public TimeSpan NotificationInterval { get; set; } = TimeSpan.FromSeconds(70);
+
+ ///
+ /// Gets or sets the time after the start of the battle, at which the army starts to attack the statue.
+ ///
+ public TimeSpan StatueAttackDelay { get; set; } = TimeSpan.FromMinutes(5);
+
+ ///
+ /// Gets or sets the time after the start of the battle, at which Balgass appears.
+ ///
+ public TimeSpan BalgassAppearanceDelay { get; set; } = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Gets or sets the number of the statue of the holy wolf.
+ ///
+ public short StatueNumber { get; set; } = 204;
+
+ ///
+ /// Gets or sets the numbers of the altars, in the order which is expected by the client.
+ ///
+ public IList AltarNumbers { get; set; } = new List { 205, 206, 207, 208, 209 };
+
+ ///
+ /// Gets or sets the numbers of the character classes, which can contract an altar.
+ ///
+ public IList ContractCharacterClassNumbers { get; set; } = new List { 8, 10, 11 };
+
+ ///
+ /// Gets or sets the minimum level of a character to contract an altar.
+ ///
+ public int MinimumContractLevel { get; set; } = 260;
+
+ ///
+ /// Gets or sets the time which an elf has to stay at the altar until the contract is valid.
+ ///
+ public TimeSpan ContractDelay { get; set; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Gets or sets the number of contracts per altar and event. A failed attempt counts as contract as well.
+ ///
+ public int ContractsPerAltar { get; set; } = 2;
+
+ ///
+ /// Gets or sets the time after a contract ended, in which the altar can't be contracted.
+ ///
+ public TimeSpan AltarCooldown { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets the number of the Dark Elf, which leads a group of the army.
+ ///
+ public short DarkElfNumber { get; set; } = 340;
+
+ ///
+ /// Gets or sets the groups of the army of Balgass.
+ ///
+ public IList MonsterGroups { get; set; } = new List
+ {
+ new() { WaveNumber = 1, AdvanceGoalX = 128, AdvanceGoalY = 45, AttackGoalX = 126, AttackGoalY = 35 },
+ new() { WaveNumber = 2, AdvanceGoalX = 109, AdvanceGoalY = 42, AttackGoalX = 125, AttackGoalY = 27 },
+ new() { WaveNumber = 3, AdvanceGoalX = 123, AdvanceGoalY = 84, AttackGoalX = 121, AttackGoalY = 19 },
+ new() { WaveNumber = 4, AdvanceGoalX = 119, AdvanceGoalY = 87, AttackGoalX = 119, AttackGoalY = 87 },
+ new() { WaveNumber = 6, AdvanceGoalX = 105, AdvanceGoalY = 32, AttackGoalX = 120, AttackGoalY = 38 },
+ new() { WaveNumber = 7, AdvanceGoalX = 89, AdvanceGoalY = 32, AttackGoalX = 115, AttackGoalY = 32 },
+ new() { WaveNumber = 8, AdvanceGoalX = 83, AdvanceGoalY = 29, AttackGoalX = 109, AttackGoalY = 40 },
+ new() { WaveNumber = 9, AdvanceGoalX = 65, AdvanceGoalY = 34, AttackGoalX = 65, AttackGoalY = 34 },
+ new() { WaveNumber = 10, AdvanceGoalX = 136, AdvanceGoalY = 38, AttackGoalX = 117, AttackGoalY = 27 },
+ new() { WaveNumber = 11, AdvanceGoalX = 134, AdvanceGoalY = 19, AttackGoalX = 117, AttackGoalY = 37 },
+ new() { WaveNumber = 12, AdvanceGoalX = 177, AdvanceGoalY = 37, AttackGoalX = 131, AttackGoalY = 31 },
+ new() { WaveNumber = 13, AdvanceGoalX = 179, AdvanceGoalY = 33, AttackGoalX = 179, AttackGoalY = 33 },
+ };
+
+ ///
+ /// Gets or sets the ballistas of the army, which bombard the fortress.
+ ///
+ public IList Ballistas { get; set; } = new List
+ {
+ new() { X = 122, Y = 90, TargetX = 121, TargetY = 52 },
+ new() { X = 116, Y = 90, TargetX = 120, TargetY = 35 },
+ new() { X = 62, Y = 37, TargetX = 101, TargetY = 31 },
+ new() { X = 62, Y = 31, TargetX = 117, TargetY = 31 },
+ new() { X = 183, Y = 36, TargetX = 141, TargetY = 32 },
+ new() { X = 183, Y = 30, TargetX = 125, TargetY = 31 },
+ };
+
+ ///
+ /// Gets or sets the maximum random offset of the bombarded point of a ballista.
+ ///
+ public int BallistaTargetSpread { get; set; } = 4;
+
+ ///
+ /// Gets or sets the radius around the bombarded point, in which the players are hit.
+ ///
+ public int BallistaHitRadius { get; set; } = 6;
+
+ ///
+ /// Gets or sets the radius around the bombarded point, in which the players see the arrow of the ballista.
+ ///
+ public int BallistaEffectRadius { get; set; } = 10;
+
+ ///
+ /// Gets or sets the interval, in which the leader of a group revives one of its dead members.
+ ///
+ public TimeSpan MemberReviveInterval { get; set; } = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Gets or sets the distance to the leader, above which the members of a group follow it.
+ ///
+ public int FollowLeaderDistance { get; set; } = 6;
+
+ ///
+ /// Gets or sets the wave number of the monster spawn of Balgass.
+ ///
+ public byte BalgassWaveNumber { get; set; } = 5;
+
+ ///
+ /// Gets or sets the x coordinate, to which Balgass marches.
+ ///
+ public byte BalgassGoalX { get; set; } = 121;
+
+ ///
+ /// Gets or sets the y coordinate, to which Balgass marches.
+ ///
+ public byte BalgassGoalY { get; set; } = 36;
+
+ ///
+ /// Gets or sets the interval, in which Balgass acts. Like in the original game, it's shorter than his attack delay.
+ ///
+ public TimeSpan BalgassActionInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the health, below which Balgass tries to escape from his target. When it's 0, he doesn't escape.
+ ///
+ public int BalgassEscapeHealth { get; set; } = 10_000;
+
+ ///
+ /// Gets or sets the chance in percent, with which Balgass escapes instead of attacking, when his health is below .
+ ///
+ public int BalgassEscapeChance { get; set; } = 70;
+
+ ///
+ /// Gets or sets the distance, which Balgass walks away from his target when he escapes.
+ ///
+ public int BalgassEscapeDistance { get; set; } = 5;
+
+ ///
+ /// Gets or sets the chance in percent, with which a monster of the army uses one of its instead of a normal attack.
+ ///
+ public int SkillChance { get; set; } = 75;
+
+ ///
+ /// Gets or sets the number of the skill, which the client shows when a Dark Elf revives a member of its group.
+ ///
+ public short ReviveSkillNumber { get; set; } = 6;
+
+ ///
+ /// Gets or sets the skills of the monsters of the army, like the monster skill units of the original game.
+ ///
+ public IList MonsterSkills { get; set; } = new List
+ {
+ // Balgass: two skills around him, which push and stun the players, and remove the greater defense of the elves.
+ new() { MonsterNumber = 349, SkillNumber = 12, Radius = 6, PushChance = 50, StunChance = 50, RemovedMagicEffectNumber = 2, RemoveEffectChance = 50 },
+
+ // The original game rolls the stun of this skill twice with 50 %.
+ new() { MonsterNumber = 349, SkillNumber = 13, Radius = 6, PushChance = 50, StunChance = 75 },
+
+ // Dark Elf: stuns its target.
+ new() { MonsterNumber = 340, SkillNumber = 8, StunChance = 50 },
+
+ // Balram: removes the greater defense of its target.
+ new() { MonsterNumber = 344, SkillNumber = 9, RemovedMagicEffectNumber = 2, RemoveEffectChance = 50 },
+
+ // Death Spirit: decreases the mana and the ability of its target.
+ new() { MonsterNumber = 345, SkillNumber = 10, ManaDecreaseChance = 50, AbilityDecreaseChance = 50 },
+
+ // Soram: pushes the players around it.
+ new() { MonsterNumber = 341, SkillNumber = 11, Radius = 6, PushChance = 50 },
+ };
+
+ ///
+ /// Gets or sets the scores which the players get for killing the monsters of the army.
+ ///
+ public IList MonsterScores { get; set; } = new List
+ {
+ new() { MonsterNumber = 340, Score = 3000 },
+ new() { MonsterNumber = 341, Score = 700 },
+ new() { MonsterNumber = 344, Score = 600 },
+ new() { MonsterNumber = 345, Score = 600 },
+ new() { MonsterNumber = 348, Score = 1000 },
+ new() { MonsterNumber = 349, Score = 7000 },
+ };
+
+ ///
+ /// Gets or sets the score, which an elf gets for having contracted an altar until the end of the battle.
+ ///
+ public int ContractorScore { get; set; } = 6000;
+
+ ///
+ /// Gets or sets the minimum scores of the ranks D, C, B, A and S.
+ ///
+ public IList RankScores { get; set; } = new List { 0, 1001, 3001, 5001, 10001 };
+
+ ///
+ /// Gets or sets the experience of the ranks D, C, B, A and S.
+ ///
+ public IList RankExperience { get; set; } = new List { 0, 200_000, 800_000, 1_200_000, 1_800_000 };
+
+ ///
+ /// Gets or sets the percentage of the rank experience, which the players get when the fortress has been occupied.
+ ///
+ public int FailedExperiencePercentage { get; set; } = 10;
+
+ ///
+ /// Gets or sets the number of heroes, which are shown in the result and rewarded.
+ ///
+ public int HeroCount { get; set; } = 5;
+
+ ///
+ /// Gets or sets the group of the item, which is dropped for the contractors and heroes when the fortress has been defended.
+ ///
+ public byte RewardItemGroup { get; set; } = 14;
+
+ ///
+ /// Gets or sets the number of the item, which is dropped for the contractors and heroes when the fortress has been defended.
+ ///
+ public short RewardItemNumber { get; set; } = 13;
+
+ ///
+ /// Gets or sets the numbers of the NPCs of the crywolf map, which stay visible when the fortress is occupied.
+ ///
+ public IList AlwaysVisibleNpcNumbers { get; set; } = new List { 406, 407 };
+
+ ///
+ /// Gets or sets a value indicating whether the benefits apply on all game servers, while the fortress is
+ /// in peace after it has been defended: and .
+ ///
+ public bool IsBenefitActive { get; set; } = true;
+
+ ///
+ /// Gets or sets the additional success rate in percent of the chaos machine mixes of the tickets
+ /// of Devil Square, Blood Castle and Illusion Temple, while the benefits apply.
+ ///
+ public byte ChaosRateBenefit { get; set; } = 5;
+
+ ///
+ /// Gets or sets the maximum health of the monsters in percent of their normal maximum health, while the benefits apply.
+ ///
+ public int MonsterHealthBenefitPercentage { get; set; } = 90;
+
+ ///
+ /// Gets or sets a value indicating whether the penalties apply on all game servers, while the fortress is
+ /// occupied: and .
+ ///
+ public bool IsPenaltyActive { get; set; }
+
+ ///
+ /// Gets or sets the chance in percent, with which a jewel which is dropped by a monster still drops, while the penalties apply.
+ ///
+ public int JewelDropPenaltyPercentage { get; set; } = 70;
+
+ ///
+ /// Gets or sets the jewels which drop less often while the penalties apply. The defaults are the ones of the original game.
+ ///
+ public IList PenaltyJewels { get; set; } = new List
+ {
+ new() { Group = 14, Number = 13 }, // Jewel of Bless
+ new() { Group = 14, Number = 14 }, // Jewel of Soul
+ new() { Group = 14, Number = 16 }, // Jewel of Life
+ new() { Group = 14, Number = 22 }, // Jewel of Creation
+ new() { Group = 12, Number = 15 }, // Jewel of Chaos
+ new() { Group = 14, Number = 31 }, // Jewel of Guardian
+ };
+
+ ///
+ /// Gets or sets the experience of killed monsters in percent of the normal experience, while the penalties apply.
+ ///
+ public int ExperiencePenaltyPercentage { get; set; } = 100;
+
+ ///
+ /// Determines whether the event starts at the specified local time of the server.
+ ///
+ /// The local time of the server.
+ /// true, if the event starts at the specified time; otherwise, false.
+ public bool IsStartTime(DateTime localTime)
+ {
+ var time = TimeOnly.FromDateTime(localTime);
+ var earlier = time.Add(TimeSpan.FromSeconds(-5));
+ return this.StartDays.Contains(localTime.DayOfWeek)
+ && this.StartTimes.Any(startTime => startTime.IsBetween(earlier, time));
+ }
+
+ ///
+ /// Gets the duration of the specified state.
+ ///
+ /// The state.
+ /// The duration of the state, or if the state has no duration.
+ public TimeSpan GetDuration(CrywolfState state)
+ {
+ return state switch
+ {
+ CrywolfState.Notify1 => this.Notify1Duration,
+ CrywolfState.Notify2 => this.Notify2Duration,
+ CrywolfState.Ready => this.ReadyDuration,
+ CrywolfState.Start => this.BattleDuration,
+ CrywolfState.End => this.EndDuration,
+ CrywolfState.EndCycle => this.EndCycleDuration,
+ _ => TimeSpan.Zero,
+ };
+ }
+
+ ///
+ /// Gets the score which a player gets for killing a monster.
+ ///
+ /// The number of the monster.
+ /// The score.
+ public int GetScore(short monsterNumber)
+ {
+ return this.MonsterScores.FirstOrDefault(score => score.MonsterNumber == monsterNumber)?.Score ?? 0;
+ }
+
+ ///
+ /// Gets the rank (0 = D to 4 = S) of a score.
+ ///
+ /// The score.
+ /// The rank.
+ public int GetRank(int score)
+ {
+ var rank = 0;
+ for (var i = 0; i < this.RankScores.Count; i++)
+ {
+ if (score >= this.RankScores[i])
+ {
+ rank = i;
+ }
+ }
+
+ return Math.Min(rank, 4);
+ }
+
+ ///
+ /// Gets the experience which a player gets for its rank.
+ ///
+ /// The rank.
+ /// If set to true, the fortress has been defended.
+ /// The experience.
+ public int GetExperience(int rank, bool isDefended)
+ {
+ var experience = rank >= 0 && rank < this.RankExperience.Count ? this.RankExperience[rank] : 0;
+ return isDefended ? experience : (int)((long)experience * this.FailedExperiencePercentage / 100);
+ }
+
+ ///
+ /// Gets the index (0 to 4) of an altar.
+ ///
+ /// The number of the altar.
+ /// The index of the altar, or -1 if it's no altar.
+ public int GetAltarIndex(short monsterNumber)
+ {
+ return this.AltarNumbers.IndexOf(monsterNumber);
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfHero.cs b/src/GameLogic/Crywolf/CrywolfHero.cs
new file mode 100644
index 0000000000..e6ea491352
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfHero.cs
@@ -0,0 +1,13 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// A hero of the crywolf event, which is one of the players with the highest scores.
+///
+/// The name of the character.
+/// The score.
+/// The number of the character class.
+public record CrywolfHero(string Name, int Score, byte CharacterClassNumber);
diff --git a/src/GameLogic/Crywolf/CrywolfItemIdentifier.cs b/src/GameLogic/Crywolf/CrywolfItemIdentifier.cs
new file mode 100644
index 0000000000..48f2b813f0
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfItemIdentifier.cs
@@ -0,0 +1,39 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using MUnique.OpenMU.DataModel.Configuration.Items;
+
+///
+/// Identifies an item definition by its group and number, e.g. a jewel which drops less often while the crywolf fortress is occupied.
+///
+public class CrywolfItemIdentifier
+{
+ ///
+ /// Gets or sets the group of the item.
+ ///
+ public byte Group { get; set; }
+
+ ///
+ /// Gets or sets the number of the item.
+ ///
+ public short Number { get; set; }
+
+ ///
+ /// Determines whether the item identifier matches the specified item definition.
+ ///
+ /// The item definition.
+ /// true, if it matches; otherwise, false.
+ public bool Matches(ItemDefinition definition)
+ {
+ return definition.Group == this.Group && definition.Number == this.Number;
+ }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.Group},{this.Number}";
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterGroup.cs b/src/GameLogic/Crywolf/CrywolfMonsterGroup.cs
new file mode 100644
index 0000000000..b476159ff4
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterGroup.cs
@@ -0,0 +1,49 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// A group of the army of Balgass, which is led by a Dark Elf.
+///
+///
+/// The members of a group are defined by the monster spawns of the crywolf map with the trigger
+/// and the of the group.
+/// When the battle starts, the leader marches to the /.
+/// When the army starts to attack the statue, it marches to the /.
+/// The other members of the group follow their leader.
+///
+public class CrywolfMonsterGroup
+{
+ ///
+ /// Gets or sets the wave number of the monster spawns of the group.
+ ///
+ public byte WaveNumber { get; set; }
+
+ ///
+ /// Gets or sets the x coordinate, to which the leader marches when the battle starts.
+ ///
+ public byte AdvanceGoalX { get; set; }
+
+ ///
+ /// Gets or sets the y coordinate, to which the leader marches when the battle starts.
+ ///
+ public byte AdvanceGoalY { get; set; }
+
+ ///
+ /// Gets or sets the x coordinate, to which the leader marches when the army attacks the statue.
+ ///
+ public byte AttackGoalX { get; set; }
+
+ ///
+ /// Gets or sets the y coordinate, to which the leader marches when the army attacks the statue.
+ ///
+ public byte AttackGoalY { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"Group {this.WaveNumber}: ({this.AdvanceGoalX}, {this.AdvanceGoalY}), ({this.AttackGoalX}, {this.AttackGoalY})";
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterGroupState.cs b/src/GameLogic/Crywolf/CrywolfMonsterGroupState.cs
new file mode 100644
index 0000000000..530c238c49
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterGroupState.cs
@@ -0,0 +1,74 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using System.Collections.Concurrent;
+using MUnique.OpenMU.GameLogic.NPC;
+
+///
+/// The state of a group of the army of Balgass during an event.
+///
+internal sealed class CrywolfMonsterGroupState
+{
+ private readonly ConcurrentDictionary _monsters = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The definition of the group.
+ /// The spawn areas of the members of the group.
+ public CrywolfMonsterGroupState(CrywolfMonsterGroup definition, IReadOnlyList spawnAreas)
+ {
+ this.Definition = definition;
+ this.SpawnAreas = spawnAreas;
+ }
+
+ ///
+ /// Gets the definition of the group.
+ ///
+ public CrywolfMonsterGroup Definition { get; }
+
+ ///
+ /// Gets the spawn areas of the members of the group.
+ ///
+ public IReadOnlyList SpawnAreas { get; }
+
+ ///
+ /// Gets the leader of the group.
+ ///
+ public Monster? Leader { get; private set; }
+
+ ///
+ /// Gets or sets the time, when the leader revived a member of the group the last time.
+ ///
+ public DateTime LastRevive { get; set; } = DateTime.MinValue;
+
+ ///
+ /// Sets the current monster of a spawn area.
+ ///
+ /// The spawn area.
+ /// The monster.
+ /// If set to true, the monster is the leader of the group.
+ public void SetMonster(MonsterSpawnArea spawnArea, Monster monster, bool isLeader)
+ {
+ this._monsters[spawnArea] = monster;
+ if (isLeader)
+ {
+ this.Leader = monster;
+ }
+ }
+
+ ///
+ /// Gets the spawn areas of the dead members, except the leader.
+ ///
+ /// The spawn areas of the dead members.
+ public IEnumerable GetDeadMembers()
+ {
+ return this._monsters
+ .Where(entry => !entry.Value.IsAlive && entry.Value != this.Leader)
+ .Select(entry => entry.Key)
+ .ToList();
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterIntelligence.cs b/src/GameLogic/Crywolf/CrywolfMonsterIntelligence.cs
new file mode 100644
index 0000000000..d9f094beb2
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterIntelligence.cs
@@ -0,0 +1,398 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using System.Threading;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The intelligence of the monsters of the army of Balgass during the crywolf event.
+///
+///
+/// The monsters don't move or attack until the battle starts. Then, like in the original game:
+///
+/// The Dark Elf which leads a group marches to the goal of the group and revives the dead members of its group.
+/// The soldiers follow their leader and attack the players around them.
+/// The ballistas bombard a point of the fortress.
+/// Balgass marches to the statue. When his health is low, he tries to escape from his target.
+///
+/// The monsters use their skills () instead of a normal attack with a chance.
+/// The goals are too far away for the path finder, so the monsters walk along the waypoints of the map.
+///
+public sealed class CrywolfMonsterIntelligence : INpcIntelligence, IDisposable
+{
+ ///
+ /// The number of the magic effect of a stun.
+ ///
+ ///
+ /// It isn't part of the configuration, because the client shows the stun by this number. The other stuns of
+ /// the game logic use the same number (e.g. ).
+ ///
+ internal const short StunnedMagicEffectNumber = 61;
+
+ ///
+ /// The distance to the goal, below which a monster walks directly to it.
+ ///
+ private const int DirectWalkDistance = 10;
+
+ ///
+ /// The maximum distance of a waypoint, which a monster walks to.
+ ///
+ private const int WaypointDistance = 20;
+
+ private readonly CrywolfContext _context;
+ private readonly CrywolfMonsterGroup? _group;
+ private readonly CrywolfBallista? _ballista;
+ private readonly ILogger _logger;
+ private Timer? _timer;
+ private Monster? _monster;
+ private IAttackable? _attacker;
+ private IReadOnlyList? _skills;
+ private int _isRunning;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The context of the event.
+ /// The role of the monster.
+ /// The group of the monster.
+ /// The definition of the ballista, if the monster is a ballista.
+ /// The logger.
+ public CrywolfMonsterIntelligence(CrywolfContext context, CrywolfMonsterRole role, CrywolfMonsterGroup? group, CrywolfBallista? ballista, ILogger logger)
+ {
+ this._context = context;
+ this.Role = role;
+ this._group = group;
+ this._ballista = ballista;
+ this._logger = logger;
+ }
+
+ ///
+ /// Gets the role of the monster.
+ ///
+ public CrywolfMonsterRole Role { get; }
+
+ ///
+ public NonPlayerCharacter Npc
+ {
+ get => this.Monster;
+ set => this.Monster = (Monster)value;
+ }
+
+ ///
+ /// Gets or sets the monster.
+ ///
+ public Monster Monster
+ {
+ get => this._monster ?? throw new InvalidOperationException("Instance is not initialized with a Monster yet");
+ set => this._monster = value;
+ }
+
+ ///
+ public bool CanWalkOnSafezone => false;
+
+ ///
+ public void RegisterHit(IAttacker attacker)
+ {
+ if (attacker is IAttackable attackable)
+ {
+ this._attacker = attackable;
+ }
+ }
+
+ ///
+ public void Start()
+ {
+ var interval = this.Role == CrywolfMonsterRole.Balgass && this._context.Definition.BalgassActionInterval > TimeSpan.Zero
+ ? this._context.Definition.BalgassActionInterval
+ : this.Monster.Definition.AttackDelay;
+ this._timer ??= new Timer(state => _ = this.SafeTickAsync(), null, interval, interval);
+ }
+
+ ///
+ ///
+ /// The army keeps marching when no player observes it, so pausing is ignored.
+ ///
+ public void Pause()
+ {
+ // intentionally left blank.
+ }
+
+ ///
+ public bool CanWalkOn(Point target)
+ {
+ return (this.Monster.CurrentMap.Terrain.AIgrid[target.X, target.Y] & 1) == 1;
+ }
+
+ ///
+ public void Dispose()
+ {
+ this._timer?.Dispose();
+ this._timer = null;
+ }
+
+ ///
+ /// Gets the point, to which a monster walks next to reach its goal.
+ /// When the goal is far away, it's the waypoint near the monster, which is the closest to the goal.
+ ///
+ /// The position of the monster.
+ /// The goal.
+ /// The point to walk to.
+ internal static Point GetNextWalkTarget(Point position, Point goal)
+ {
+ var distanceToGoal = position.EuclideanDistanceTo(goal);
+ if (distanceToGoal <= DirectWalkDistance)
+ {
+ return goal;
+ }
+
+ var waypoint = CrywolfMovePath.Waypoints
+ .Where(point => position.EuclideanDistanceTo(point) < WaypointDistance)
+ .MinBy(point => point.EuclideanDistanceTo(goal));
+ return waypoint != default && waypoint.EuclideanDistanceTo(goal) < distanceToGoal ? waypoint : goal;
+ }
+
+ ///
+ /// Executes one step of the intelligence. It's called periodically by a timer.
+ ///
+ internal async ValueTask TickAsync()
+ {
+ if (this._monster is not { IsAlive: true } monster
+ || this._context.ArmyStage == CrywolfArmyStage.Waiting
+ || monster.Attributes[Stats.IsStunned] > 0
+ || monster.Attributes[Stats.IsAsleep] > 0)
+ {
+ return;
+ }
+
+ if (this.Role == CrywolfMonsterRole.Ballista)
+ {
+ if (this._ballista is { } ballista)
+ {
+ await this._context.BombardAsync(monster, ballista).ConfigureAwait(false);
+ }
+
+ return;
+ }
+
+ if (this.Role == CrywolfMonsterRole.Leader && this._group is { } group)
+ {
+ await this._context.ReviveGroupMembersAsync(group.WaveNumber).ConfigureAwait(false);
+ }
+
+ if (monster.IsWalking)
+ {
+ return;
+ }
+
+ if (await this.SearchTargetAsync(monster).ConfigureAwait(false) is { } target)
+ {
+ await this.AttackAsync(monster, target).ConfigureAwait(false);
+ return;
+ }
+
+ if (monster.Attributes[Stats.IsFrozen] > 0 || this.GetGoal(monster) is not { } goal || monster.Position == goal)
+ {
+ return;
+ }
+
+ var walkTarget = GetNextWalkTarget(monster.Position, goal);
+ if (!await monster.WalkToAsync(walkTarget).ConfigureAwait(false) && walkTarget != goal)
+ {
+ // The waypoint can't be reached, e.g. because of the terrain. The goal might be reachable.
+ await monster.WalkToAsync(goal).ConfigureAwait(false);
+ }
+ }
+
+ private static bool IsChance(int percentage)
+ {
+ return percentage > 0 && Rand.NextInt(0, 100) < percentage;
+ }
+
+ private static async ValueTask StunAsync(Player player, TimeSpan duration)
+ {
+ if (player.Attributes is not { } attributes
+ || player.GameContext.Configuration.MagicEffects.FirstOrDefault(m => m.Number == StunnedMagicEffectNumber) is not { } definition
+ || definition.PowerUpDefinitions.FirstOrDefault(p => p.TargetAttribute == Stats.IsStunned) is not { } powerUpDefinition)
+ {
+ return;
+ }
+
+ var powerUp = attributes.CreateElement(powerUpDefinition);
+ var effect = new MagicEffect(duration, definition, [new MagicEffect.ElementWithTarget(powerUp, Stats.IsStunned)]);
+ await player.MagicEffectList.AddEffectAsync(effect).ConfigureAwait(false);
+ }
+
+ private static void Decrease(Player player, AttributeDefinition attribute, int percentage)
+ {
+ // The client gets the new value by the change of the attribute (see Player.OnAttributeValueChanged).
+ if (player.Attributes is { } attributes)
+ {
+ attributes[attribute] = attributes[attribute] * (100 - Math.Clamp(percentage, 0, 100)) / 100f;
+ }
+ }
+
+ private static bool IsValidTarget(Monster monster, IAttackable target)
+ {
+ return target is Player { IsInvisible: false }
+ && target.IsActive()
+ && !target.IsAtSafezone()
+ && target.IsInRange(monster.Position, monster.Definition.AttackRange)
+ && monster.HasLineOfSightTo(target);
+ }
+
+ private async ValueTask AttackAsync(Monster monster, IAttackable target)
+ {
+ if (this.Role == CrywolfMonsterRole.Balgass && await this.TryEscapeAsync(monster, target).ConfigureAwait(false))
+ {
+ return;
+ }
+
+ var definition = this._context.Definition;
+ this._skills ??= definition.MonsterSkills.Where(skill => skill.MonsterNumber == monster.Definition.Number).ToList();
+ if (this._skills.Count == 0 || !IsChance(definition.SkillChance))
+ {
+ await monster.AttackAsync(target).ConfigureAwait(false);
+ return;
+ }
+
+ await this.UseSkillAsync(monster, target, this._skills[Rand.NextInt(0, this._skills.Count)]).ConfigureAwait(false);
+ }
+
+ ///
+ /// Lets Balgass try to escape from his target, when his health is low, like in the original game.
+ ///
+ private async ValueTask TryEscapeAsync(Monster monster, IAttackable target)
+ {
+ var definition = this._context.Definition;
+ if (definition.BalgassEscapeHealth <= 0
+ || monster.Health >= definition.BalgassEscapeHealth
+ || !IsChance(definition.BalgassEscapeChance)
+ || monster.Attributes[Stats.IsFrozen] > 0)
+ {
+ return false;
+ }
+
+ var escapeTarget = monster.CurrentMap.Terrain.GetPointAwayFrom(target.Position, monster.Position, definition.BalgassEscapeDistance, this.CanWalkOn);
+ return escapeTarget != monster.Position && await monster.WalkToAsync(escapeTarget).ConfigureAwait(false);
+ }
+
+ private async ValueTask UseSkillAsync(Monster monster, IAttackable target, CrywolfMonsterSkill skill)
+ {
+ await monster.ForEachWorldObserverAsync(p => p.ShowMonsterSkillAsync(monster, target, skill.SkillNumber), true).ConfigureAwait(false);
+
+ IReadOnlyList players = skill.Radius > 0
+ ? monster.CurrentMap.GetAttackablesInRange(monster.Position, skill.Radius)
+ .OfType()
+ .Where(player => player.IsActive() && !player.IsAtSafezone())
+ .ToList()
+ : target is Player targetPlayer ? [targetPlayer] : [];
+ foreach (var player in players)
+ {
+ await player.AttackByAsync(monster, null, false).ConfigureAwait(false);
+ if (!player.IsAlive)
+ {
+ continue;
+ }
+
+ // The push comes first, because a stunned player isn't pushed.
+ if (IsChance(skill.PushChance))
+ {
+ await monster.PushAwayAsync(player, skill.PushDistance).ConfigureAwait(false);
+ }
+
+ if (IsChance(skill.StunChance))
+ {
+ await StunAsync(player, skill.StunDuration).ConfigureAwait(false);
+ }
+
+ if (skill.RemovedMagicEffectNumber > 0
+ && IsChance(skill.RemoveEffectChance)
+ && player.MagicEffectList.TryGetEffect(skill.RemovedMagicEffectNumber, out var effect))
+ {
+ await effect.DisposeAsync().ConfigureAwait(false);
+ }
+
+ if (IsChance(skill.ManaDecreaseChance))
+ {
+ Decrease(player, Stats.CurrentMana, skill.DecreasePercentage);
+ }
+
+ if (IsChance(skill.AbilityDecreaseChance))
+ {
+ Decrease(player, Stats.CurrentAbility, skill.DecreasePercentage);
+ }
+ }
+ }
+
+ private Point? GetGoal(Monster monster)
+ {
+ var definition = this._context.Definition;
+ switch (this.Role)
+ {
+ case CrywolfMonsterRole.Balgass:
+ return new Point(definition.BalgassGoalX, definition.BalgassGoalY);
+ case CrywolfMonsterRole.Leader when this._group is { } group:
+ return this._context.ArmyStage == CrywolfArmyStage.AttackingStatue
+ ? new Point(group.AttackGoalX, group.AttackGoalY)
+ : new Point(group.AdvanceGoalX, group.AdvanceGoalY);
+ case CrywolfMonsterRole.Soldier when this._group is { } group:
+ if (this._context.GetLeader(group.WaveNumber) is { } leader
+ && !leader.IsInRange(monster.Position, definition.FollowLeaderDistance))
+ {
+ return leader.Position;
+ }
+
+ return null;
+ default:
+ return null;
+ }
+ }
+
+ private async ValueTask SearchTargetAsync(Monster monster)
+ {
+ if (this._attacker is { } attacker && IsValidTarget(monster, attacker))
+ {
+ return attacker;
+ }
+
+ this._attacker = null;
+ List candidates;
+ using (await monster.ObserverLock.ReaderLockAsync())
+ {
+ candidates = monster.Observers.OfType().Where(candidate => IsValidTarget(monster, candidate)).ToList();
+ }
+
+ return candidates.MinBy(candidate => candidate.GetDistanceTo(monster));
+ }
+
+ private async Task SafeTickAsync()
+ {
+ if (Interlocked.Exchange(ref this._isRunning, 1) != 0)
+ {
+ return;
+ }
+
+ try
+ {
+ await this.TickAsync().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // expected during shutdown.
+ }
+ catch (Exception ex)
+ {
+ this._logger.LogError(ex, "Unexpected error in the crywolf intelligence of {monster}.", this._monster);
+ }
+ finally
+ {
+ Interlocked.Exchange(ref this._isRunning, 0);
+ }
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterRole.cs b/src/GameLogic/Crywolf/CrywolfMonsterRole.cs
new file mode 100644
index 0000000000..7435aa106d
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterRole.cs
@@ -0,0 +1,31 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The role of a monster of the army of Balgass.
+///
+public enum CrywolfMonsterRole
+{
+ ///
+ /// The Dark Elf which leads a group: it marches to the goals of the group and revives its members.
+ ///
+ Leader,
+
+ ///
+ /// A soldier which follows the leader of its group.
+ ///
+ Soldier,
+
+ ///
+ /// A ballista which bombards a point of the fortress.
+ ///
+ Ballista,
+
+ ///
+ /// Balgass, which marches to the statue.
+ ///
+ Balgass,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterScore.cs b/src/GameLogic/Crywolf/CrywolfMonsterScore.cs
new file mode 100644
index 0000000000..0d4ff05dd6
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterScore.cs
@@ -0,0 +1,27 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The score which a player gets for killing a monster of the army of Balgass.
+///
+public class CrywolfMonsterScore
+{
+ ///
+ /// Gets or sets the number of the monster.
+ ///
+ public short MonsterNumber { get; set; }
+
+ ///
+ /// Gets or sets the score.
+ ///
+ public int Score { get; set; }
+
+ ///
+ public override string ToString()
+ {
+ return $"{this.MonsterNumber}: {this.Score}";
+ }
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMonsterSkill.cs b/src/GameLogic/Crywolf/CrywolfMonsterSkill.cs
new file mode 100644
index 0000000000..c90582ee76
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMonsterSkill.cs
@@ -0,0 +1,74 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// A skill of a monster of the army of Balgass, like the monster skill units of the original game.
+///
+///
+/// Each effect is rolled separately for each hit player.
+///
+public class CrywolfMonsterSkill
+{
+ ///
+ /// Gets or sets the number of the monster.
+ ///
+ public short MonsterNumber { get; set; }
+
+ ///
+ /// Gets or sets the number of the skill, which the client uses to show its animation.
+ ///
+ public short SkillNumber { get; set; }
+
+ ///
+ /// Gets or sets the radius around the monster, in which the skill hits the players. When it's 0, it only hits the target.
+ ///
+ public int Radius { get; set; }
+
+ ///
+ /// Gets or sets the chance in percent to push the hit players away from the monster.
+ ///
+ public int PushChance { get; set; }
+
+ ///
+ /// Gets or sets the distance, by which the hit players are pushed away.
+ ///
+ public int PushDistance { get; set; } = 3;
+
+ ///
+ /// Gets or sets the chance in percent to stun the hit players.
+ ///
+ public int StunChance { get; set; }
+
+ ///
+ /// Gets or sets the duration of the stun.
+ ///
+ public TimeSpan StunDuration { get; set; } = TimeSpan.FromSeconds(3);
+
+ ///
+ /// Gets or sets the number of the magic effect, which is removed from the hit players, e.g. the greater defense of the elves.
+ ///
+ public short RemovedMagicEffectNumber { get; set; }
+
+ ///
+ /// Gets or sets the chance in percent to remove the .
+ ///
+ public int RemoveEffectChance { get; set; }
+
+ ///
+ /// Gets or sets the chance in percent to decrease the mana of the hit players by .
+ ///
+ public int ManaDecreaseChance { get; set; }
+
+ ///
+ /// Gets or sets the chance in percent to decrease the ability (AG) of the hit players by .
+ ///
+ public int AbilityDecreaseChance { get; set; }
+
+ ///
+ /// Gets or sets the percentage, by which the mana or ability is decreased.
+ ///
+ public int DecreasePercentage { get; set; } = 50;
+}
diff --git a/src/GameLogic/Crywolf/CrywolfMovePath.cs b/src/GameLogic/Crywolf/CrywolfMovePath.cs
new file mode 100644
index 0000000000..743627032a
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfMovePath.cs
@@ -0,0 +1,245 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// The waypoints of the crywolf map, along which the monsters of the army of Balgass march to their goals.
+///
+///
+/// The goals of the monsters are too far away for the path finder. So, like in the original game,
+/// a monster walks to the waypoint near it, which is the closest to its goal, until it's near the goal.
+///
+internal static class CrywolfMovePath
+{
+ ///
+ /// Gets the waypoints.
+ ///
+ public static IReadOnlyList Waypoints { get; } =
+ [
+ new(43, 61),
+ new(47, 55),
+ new(54, 46),
+ new(63, 40),
+ new(70, 41),
+ new(79, 33),
+ new(79, 52),
+ new(87, 63),
+ new(86, 81),
+ new(82, 97),
+ new(77, 112),
+ new(70, 112),
+ new(80, 138),
+ new(88, 149),
+ new(99, 169),
+ new(108, 186),
+ new(112, 200),
+ new(115, 168),
+ new(120, 155),
+ new(123, 141),
+ new(123, 126),
+ new(120, 112),
+ new(116, 103),
+ new(111, 98),
+ new(116, 88),
+ new(114, 76),
+ new(102, 76),
+ new(136, 74),
+ new(152, 63),
+ new(125, 75),
+ new(145, 67),
+ new(162, 55),
+ new(170, 47),
+ new(173, 37),
+ new(183, 35),
+ new(192, 42),
+ new(199, 48),
+ new(207, 59),
+ new(205, 49),
+ new(212, 69),
+ new(215, 78),
+ new(216, 89),
+ new(227, 89),
+ new(235, 100),
+ new(235, 121),
+ new(222, 120),
+ new(210, 105),
+ new(189, 83),
+ new(175, 83),
+ new(203, 86),
+ new(164, 85),
+ new(154, 83),
+ new(142, 83),
+ new(213, 116),
+ new(207, 123),
+ new(204, 130),
+ new(202, 138),
+ new(197, 144),
+ new(185, 150),
+ new(176, 151),
+ new(166, 150),
+ new(164, 140),
+ new(162, 127),
+ new(158, 120),
+ new(153, 111),
+ new(148, 104),
+ new(144, 98),
+ new(138, 89),
+ new(125, 86),
+ new(158, 155),
+ new(153, 159),
+ new(148, 164),
+ new(145, 170),
+ new(144, 176),
+ new(143, 182),
+ new(142, 192),
+ new(135, 198),
+ new(123, 201),
+ new(156, 193),
+ new(164, 193),
+ new(176, 194),
+ new(184, 199),
+ new(196, 199),
+ new(205, 200),
+ new(217, 193),
+ new(224, 186),
+ new(232, 174),
+ new(235, 159),
+ new(235, 140),
+ new(229, 128),
+ new(235, 109),
+ new(160, 206),
+ new(145, 204),
+ new(153, 215),
+ new(144, 220),
+ new(143, 211),
+ new(169, 208),
+ new(170, 220),
+ new(161, 224),
+ new(111, 213),
+ new(99, 199),
+ new(86, 201),
+ new(81, 208),
+ new(73, 214),
+ new(63, 209),
+ new(54, 203),
+ new(48, 196),
+ new(46, 183),
+ new(47, 175),
+ new(52, 166),
+ new(56, 159),
+ new(62, 152),
+ new(68, 146),
+ new(60, 113),
+ new(52, 118),
+ new(46, 123),
+ new(39, 126),
+ new(31, 122),
+ new(26, 119),
+ new(20, 114),
+ new(22, 102),
+ new(25, 93),
+ new(30, 84),
+ new(34, 76),
+ new(39, 68),
+ new(16, 123),
+ new(15, 132),
+ new(15, 140),
+ new(24, 130),
+ new(15, 148),
+ new(19, 159),
+ new(30, 170),
+ new(38, 174),
+ new(71, 22),
+ new(65, 29),
+ new(94, 159),
+ new(77, 130),
+ new(74, 123),
+ new(85, 89),
+ new(94, 80),
+ new(86, 71),
+ new(140, 71),
+ new(103, 208),
+ new(207, 94),
+ new(176, 166),
+ new(173, 176),
+ new(168, 185),
+ new(75, 27),
+ new(68, 212),
+ new(97, 178),
+ new(93, 188),
+ new(91, 195),
+ new(115, 181),
+ new(65, 200),
+ new(170, 200),
+ new(29, 133),
+ new(120, 63),
+ new(120, 58),
+ new(120, 52),
+ new(120, 48),
+ new(120, 43),
+ new(120, 38),
+ new(113, 46),
+ new(110, 42),
+ new(106, 38),
+ new(84, 32),
+ new(88, 31),
+ new(94, 31),
+ new(99, 31),
+ new(103, 31),
+ new(157, 32),
+ new(152, 32),
+ new(147, 32),
+ new(141, 31),
+ new(136, 31),
+ new(130, 31),
+ new(126, 31),
+ new(124, 26),
+ new(121, 25),
+ new(114, 31),
+ new(109, 31),
+ new(116, 36),
+ new(130, 21),
+ new(126, 16),
+ new(136, 23),
+ new(119, 15),
+ new(111, 17),
+ new(129, 45),
+ new(139, 38),
+ new(128, 38),
+ new(106, 23),
+ new(113, 22),
+ new(117, 27),
+ new(125, 36),
+ new(81, 28),
+ new(75, 20),
+ new(73, 16),
+ new(73, 36),
+ new(81, 39),
+ new(77, 44),
+ new(67, 36),
+ new(178, 37),
+ new(168, 37),
+ new(162, 32),
+ new(165, 33),
+ new(187, 39),
+ new(176, 32),
+ new(170, 30),
+ new(120, 69),
+ new(157, 59),
+ new(119, 79),
+ new(119, 74),
+ new(78, 24),
+ new(83, 57),
+ new(109, 79),
+ new(116, 69),
+ new(164, 39),
+ new(160, 35),
+ new(168, 43),
+ new(135, 42),
+ new(124, 117),
+ ];
+}
diff --git a/src/GameLogic/Crywolf/CrywolfOccupationState.cs b/src/GameLogic/Crywolf/CrywolfOccupationState.cs
new file mode 100644
index 0000000000..2c0fa9ec76
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfOccupationState.cs
@@ -0,0 +1,26 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+///
+/// The occupation state of the crywolf fortress, matching the values expected by the client.
+///
+public enum CrywolfOccupationState : byte
+{
+ ///
+ /// The fortress has been defended.
+ ///
+ Peace = 0,
+
+ ///
+ /// The fortress has been occupied by Balgass. The common NPCs of the map are hidden.
+ ///
+ Occupied = 1,
+
+ ///
+ /// The fortress is under attack.
+ ///
+ War = 2,
+}
diff --git a/src/GameLogic/Crywolf/CrywolfPlugIn.cs b/src/GameLogic/Crywolf/CrywolfPlugIn.cs
new file mode 100644
index 0000000000..fa1645a833
--- /dev/null
+++ b/src/GameLogic/Crywolf/CrywolfPlugIn.cs
@@ -0,0 +1,394 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameLogic.Crywolf;
+
+using System.Collections.Concurrent;
+using System.ComponentModel;
+using System.Runtime.CompilerServices;
+using System.Runtime.InteropServices;
+using System.Threading;
+using MUnique.OpenMU.AttributeSystem;
+using MUnique.OpenMU.GameLogic.Attributes;
+using MUnique.OpenMU.GameLogic.NPC;
+using MUnique.OpenMU.GameLogic.PlugIns;
+using MUnique.OpenMU.Pathfinding;
+using MUnique.OpenMU.PlugIns;
+
+///
+/// The plugin of the crywolf event, in which the players defend the crywolf fortress against the army of Balgass.
+///
+///
+/// The event starts at the configured times. The result of the event, the occupation state of the fortress,
+/// is kept until the next event and saved in the database. It results in benefits or penalties on all game servers:
+///
+/// While the fortress is in peace after it has been defended, the maximum health of the monsters is reduced,
+/// and the chaos machine mixes of the event tickets get an additional success rate.
+/// While the fortress is occupied, the jewels drop less often, and the experience of killed monsters is reduced.
+///
+///
+[PlugIn]
+[Display(Name = nameof(PlugInResources.CrywolfPlugIn_Name), Description = nameof(PlugInResources.CrywolfPlugIn_Description), ResourceType = typeof(PlugInResources))]
+[Guid("3E8B5C27-9D41-4A6F-B2C8-7F1D6E0A9B34")]
+public sealed class CrywolfPlugIn : IFeaturePlugIn, IPeriodicTaskPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration,
+ IObjectAddedToMapPlugIn, IObjectRemovedFromMapPlugIn, IPlayerStateChangedPlugIn, IMonsterItemDropPlugIn, IDisposable
+{
+ private readonly ConcurrentDictionary _contexts = new();
+ private readonly ConcurrentDictionary _runningTicks = new();
+
+ ///
+ /// The multiplier of the maximum health of the monsters. Like the occupation state, it's the same for all game servers.
+ ///
+ private readonly SimpleElement _monsterHealthMultiplier = new(1.0f, AggregateType.Multiplicate);
+
+ ///
+ /// The multiplier of the and of the players.
+ ///
+ private readonly SimpleElement _experienceMultiplier = new(1.0f, AggregateType.Multiplicate);
+
+ ///
+ /// The attribute systems of the monsters and players, to which the multipliers are attached.
+ ///
+ private readonly ConditionalWeakTable