From 416af1e5e953e2e078bd3f0fcd8d510055f4c6cc Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 1 Jan 2026 18:43:59 +0000
Subject: [PATCH 01/10] Initial plan
From 68958903643bf8e48759706f4e4834b3026c568e Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 1 Jan 2026 18:54:23 +0000
Subject: [PATCH 02/10] Add projectile-based targeting for Triple Shot skill
- Added ProjectileCount property to AreaSkillSettings
- Modified FrustumBasedTargetFilter to support multiple projectiles
- Updated AreaSkillAttackAction to track which projectiles can hit each target
- Configured Triple Shot to use 3 projectiles evenly distributed within frustum
Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com>
---
.../Configuration/AreaSkillSettings.cs | 8 ++
.../Skills/AreaSkillAttackAction.cs | 38 +++++-
.../Skills/FrustumBasedTargetFilter.cs | 109 +++++++++++++++++-
.../Skills/SkillsInitializerBase.cs | 5 +-
.../Version095d/SkillsInitializer.cs | 2 +-
.../VersionSeasonSix/SkillsInitializer.cs | 2 +-
6 files changed, 157 insertions(+), 7 deletions(-)
diff --git a/src/DataModel/Configuration/AreaSkillSettings.cs b/src/DataModel/Configuration/AreaSkillSettings.cs
index 987e5a942..726019f17 100644
--- a/src/DataModel/Configuration/AreaSkillSettings.cs
+++ b/src/DataModel/Configuration/AreaSkillSettings.cs
@@ -80,6 +80,14 @@ public partial class AreaSkillSettings
///
public float HitChancePerDistanceMultiplier { get; set; }
+ ///
+ /// Gets or sets the number of projectiles/arrows that are fired.
+ /// When greater than 1, the projectiles are evenly distributed within the frustum.
+ /// Each target can only be hit by projectiles whose paths cross the target's position.
+ /// Default is 1 (single projectile).
+ ///
+ public int ProjectileCount { get; set; }
+
///
public override string ToString()
{
diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
index caf326a3d..4e6bd0a07 100644
--- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
+++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
@@ -111,7 +111,7 @@ private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTar
}
else
{
- extraTarget = await this.AttackTargetsAsync(player, extraTargetId, targetAreaCenter, skillEntry, areaSkillSettings, targets, isCombo).ConfigureAwait(false);
+ extraTarget = await this.AttackTargetsAsync(player, extraTargetId, targetAreaCenter, skillEntry, skill, areaSkillSettings, targets, rotation, isCombo).ConfigureAwait(false);
}
if (isCombo)
@@ -120,13 +120,33 @@ private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTar
}
}
- private async Task AttackTargetsAsync(Player player, ushort extraTargetId, Point targetAreaCenter, SkillEntry skillEntry, AreaSkillSettings areaSkillSettings, IEnumerable targets, bool isCombo)
+ private async Task AttackTargetsAsync(Player player, ushort extraTargetId, Point targetAreaCenter, SkillEntry skillEntry, Skill skill, AreaSkillSettings areaSkillSettings, IEnumerable targets, byte rotation, bool isCombo)
{
IAttackable? extraTarget = null;
var attackCount = 0;
var maxAttacks = areaSkillSettings.MaximumNumberOfHitsPerAttack == 0 ? int.MaxValue : areaSkillSettings.MaximumNumberOfHitsPerAttack;
var currentDelay = TimeSpan.Zero;
+ // For skills with multiple projectiles, track which projectiles hit which targets
+ Dictionary>? targetToProjectileMap = null;
+ FrustumBasedTargetFilter? filter = null;
+
+ if (areaSkillSettings is { UseFrustumFilter: true, ProjectileCount: > 1 })
+ {
+ filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance, s.ProjectileCount));
+ targetToProjectileMap = new Dictionary>();
+
+ // Determine which projectiles can hit each target
+ foreach (var target in targets)
+ {
+ var projectiles = filter.GetProjectilesThatCanHitTarget(player, target, rotation);
+ if (projectiles.Count > 0)
+ {
+ targetToProjectileMap[target] = new List(projectiles);
+ }
+ }
+ }
+
for (int attackRound = 0; attackRound < areaSkillSettings.MaximumNumberOfHitsPerTarget; attackRound++)
{
if (attackCount >= maxAttacks)
@@ -146,6 +166,18 @@ private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTar
extraTarget = target;
}
+ // For multiple projectiles, check if there are any projectiles left that can hit this target
+ if (targetToProjectileMap != null)
+ {
+ if (!targetToProjectileMap.TryGetValue(target, out var availableProjectiles) || availableProjectiles.Count == 0)
+ {
+ continue; // No projectiles can hit this target
+ }
+
+ // Remove one projectile from the available list (it's been used for this hit)
+ availableProjectiles.RemoveAt(0);
+ }
+
var hitChance = attackRound < areaSkillSettings.MinimumNumberOfHitsPerTarget
? 1.0
: Math.Min(areaSkillSettings.HitChancePerDistanceMultiplier, Math.Pow(areaSkillSettings.HitChancePerDistanceMultiplier, player.GetDistanceTo(target)));
@@ -231,7 +263,7 @@ private static IEnumerable GetTargetsInRange(Player player, Point t
if (skill.AreaSkillSettings is { UseFrustumFilter: true } areaSkillSettings)
{
- var filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance));
+ var filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance, s.ProjectileCount > 0 ? s.ProjectileCount : 1));
targetsInRange = targetsInRange.Where(a => filter.IsTargetWithinBounds(player, a, rotation));
}
diff --git a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
index e68433b59..7ae277f26 100644
--- a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
+++ b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
@@ -21,11 +21,13 @@ public record FrustumBasedTargetFilter
/// The width of the frustum at the start.
/// The width of the frustum at the end.
/// The distance.
- public FrustumBasedTargetFilter(float startWidth, float endWidth, float distance)
+ /// The number of projectiles. Default is 1.
+ public FrustumBasedTargetFilter(float startWidth, float endWidth, float distance, int projectileCount = 1)
{
this.EndWidth = endWidth;
this.Distance = distance;
this.StartWidth = startWidth;
+ this.ProjectileCount = projectileCount;
this._rotationVectors = this.CalculateRotationVectors();
}
@@ -44,6 +46,11 @@ public FrustumBasedTargetFilter(float startWidth, float endWidth, float distance
///
public float StartWidth { get; }
+ ///
+ /// Gets the number of projectiles/arrows.
+ ///
+ public int ProjectileCount { get; }
+
///
/// Determines whether the target is within the hit bounds.
///
@@ -62,6 +69,106 @@ public bool IsTargetWithinBounds(ILocateable attacker, ILocateable target, byte
return IsWithinFrustum(frustum, target.Position);
}
+ ///
+ /// Gets the indices of projectiles that can hit the target.
+ /// When multiple projectiles are used, they are evenly distributed within the frustum.
+ ///
+ /// The attacker.
+ /// The target.
+ /// The rotation.
+ /// A list of projectile indices (0-based) that can hit the target.
+ public IReadOnlyList GetProjectilesThatCanHitTarget(ILocateable attacker, ILocateable target, byte rotation)
+ {
+ if (this.ProjectileCount <= 1)
+ {
+ // For single projectile, use the simple frustum check
+ return this.IsTargetWithinBounds(attacker, target, rotation) ? [0] : [];
+ }
+
+ var result = new List();
+
+ // Calculate the angle span of the frustum
+ var frustumAngleSpan = CalculateFrustumAngleSpan(this.StartWidth, this.EndWidth, this.Distance);
+
+ // Distribute projectiles evenly within the frustum
+ for (int i = 0; i < this.ProjectileCount; i++)
+ {
+ // Calculate the angle offset for this projectile
+ // Projectiles are evenly distributed, e.g., for 3 projectiles: -1/2, 0, +1/2 of the span
+ var angleOffset = (i - (this.ProjectileCount - 1) / 2.0) * frustumAngleSpan / (this.ProjectileCount - 1);
+
+ if (this.IsTargetWithinProjectilePath(attacker, target, rotation, angleOffset))
+ {
+ result.Add(i);
+ }
+ }
+
+ return result;
+ }
+
+ private static double CalculateFrustumAngleSpan(float startWidth, float endWidth, float distance)
+ {
+ // Calculate the angle span based on the frustum dimensions
+ // Use the larger width to get the full span
+ var maxWidth = Math.Max(startWidth, endWidth);
+ return Math.Atan2(maxWidth, distance) * 2.0;
+ }
+
+ private bool IsTargetWithinProjectilePath(ILocateable attacker, ILocateable target, byte rotation, double angleOffset)
+ {
+ // Create a narrower frustum for this specific projectile
+ // The projectile has a narrow cone around its path
+ var projectileWidth = Math.Max(this.StartWidth / this.ProjectileCount, 0.5f);
+ var projectileEndWidth = Math.Max(this.EndWidth / this.ProjectileCount, 0.5f);
+
+ // Calculate the center direction of this projectile
+ var frustum = this.GetProjectileFrustum(attacker.Position, rotation, angleOffset, projectileWidth, projectileEndWidth);
+ return IsWithinFrustum(frustum, target.Position);
+ }
+
+ private (Vector4 X, Vector4 Y) GetProjectileFrustum(Point attackerPosition, byte rotation, double angleOffset, float width, float endWidth)
+ {
+ const int degreeOffset = 180;
+ const float distanceOffset = 0.99f;
+
+ // Calculate the rotation with the projectile offset
+ var baseRotation = (rotation * 360.0) / byte.MaxValue;
+ baseRotation = (baseRotation + degreeOffset) % 360;
+ var offsetDegrees = angleOffset * (180.0 / Math.PI); // Convert radians to degrees
+ var totalDegrees = baseRotation + offsetDegrees;
+
+ var angleMatrix = CreateAngleMatrix(totalDegrees);
+
+ // Define the frustum corners for this projectile
+ var temp = new Vector3[4];
+ temp[0] = new Vector3(-endWidth, this.Distance, 0);
+ temp[1] = new Vector3(endWidth, this.Distance, 0);
+ temp[2] = new Vector3(width, distanceOffset, 0);
+ temp[3] = new Vector3(-width, distanceOffset, 0);
+
+ var rotationVectors = new Vector2[4];
+ for (int i = 0; i < temp.Length; i++)
+ {
+ rotationVectors[i] = VectorRotate(temp[i], angleMatrix);
+ }
+
+ Vector4 resultX = default;
+ Vector4 resultY = default;
+ resultX.X = (int)rotationVectors[0].X + attackerPosition.X;
+ resultY.X = (int)rotationVectors[0].Y + attackerPosition.Y;
+
+ resultX.Y = (int)rotationVectors[1].X + attackerPosition.X;
+ resultY.Y = (int)rotationVectors[1].Y + attackerPosition.Y;
+
+ resultX.Z = (int)rotationVectors[2].X + attackerPosition.X;
+ resultY.Z = (int)rotationVectors[2].Y + attackerPosition.Y;
+
+ resultX.W = (int)rotationVectors[3].X + attackerPosition.X;
+ resultY.W = (int)rotationVectors[3].Y + attackerPosition.Y;
+
+ return (resultX, resultY);
+ }
+
private static bool IsWithinFrustum((Vector4 X, Vector4 Y) frustum, Point target)
{
var isOutOfRange = (((frustum.X.X - target.X) * (frustum.Y.W - target.Y)) - ((frustum.X.W - target.X) * (frustum.Y.X - target.Y))) < 0.0f
diff --git a/src/Persistence/Initialization/Skills/SkillsInitializerBase.cs b/src/Persistence/Initialization/Skills/SkillsInitializerBase.cs
index 6d7863c1b..73ca897da 100644
--- a/src/Persistence/Initialization/Skills/SkillsInitializerBase.cs
+++ b/src/Persistence/Initialization/Skills/SkillsInitializerBase.cs
@@ -120,6 +120,7 @@ protected void CreateSkill(
/// The hit chance per distance multiplier.
/// If set to true, the skill should use a target area filter.
/// The target area diameter.
+ /// The number of projectiles/arrows. When greater than 1, they are evenly distributed within the frustum.
protected void AddAreaSkillSettings(
SkillNumber skillNumber,
bool useFrustumFilter,
@@ -134,7 +135,8 @@ protected void AddAreaSkillSettings(
int maximumHitsPerAttack = default,
float hitChancePerDistanceMultiplier = 1.0f,
bool useTargetAreaFilter = false,
- float targetAreaDiameter = default)
+ float targetAreaDiameter = default,
+ int projectileCount = 1)
{
var skill = this.GameConfiguration.Skills.First(s => s.Number == (short)skillNumber);
var areaSkillSettings = this.Context.CreateNew();
@@ -153,6 +155,7 @@ protected void AddAreaSkillSettings(
areaSkillSettings.MaximumNumberOfHitsPerTarget = maximumHitsPerTarget;
areaSkillSettings.MaximumNumberOfHitsPerAttack = maximumHitsPerAttack;
areaSkillSettings.HitChancePerDistanceMultiplier = hitChancePerDistanceMultiplier;
+ areaSkillSettings.ProjectileCount = projectileCount;
}
private void ApplyElementalModifier(ElementalType elementalModifier, Skill skill)
diff --git a/src/Persistence/Initialization/Version095d/SkillsInitializer.cs b/src/Persistence/Initialization/Version095d/SkillsInitializer.cs
index 7aea78f4f..e869df37d 100644
--- a/src/Persistence/Initialization/Version095d/SkillsInitializer.cs
+++ b/src/Persistence/Initialization/Version095d/SkillsInitializer.cs
@@ -65,7 +65,7 @@ public override void Initialize()
this.CreateSkill(SkillNumber.Cyclone, "Cyclone", CharacterClasses.DarkKnight | CharacterClasses.MagicGladiator, DamageType.Physical, distance: 2, manaConsumption: 9, movesToTarget: true, movesTarget: true);
this.CreateSkill(SkillNumber.Slash, "Slash", CharacterClasses.DarkKnight | CharacterClasses.MagicGladiator, DamageType.Physical, distance: 2, manaConsumption: 10, movesToTarget: true, movesTarget: true);
this.CreateSkill(SkillNumber.TripleShot, "Triple Shot", CharacterClasses.FairyElf, DamageType.Physical, distance: 6, manaConsumption: 5, skillType: SkillType.AreaSkillAutomaticHits);
- this.AddAreaSkillSettings(SkillNumber.TripleShot, true, 1f, 4.5f, 7f, true, TimeSpan.FromMilliseconds(50), maximumHitsPerTarget: 3, maximumHitsPerAttack: 3);
+ this.AddAreaSkillSettings(SkillNumber.TripleShot, true, 1f, 4.5f, 7f, true, TimeSpan.FromMilliseconds(50), maximumHitsPerTarget: 3, maximumHitsPerAttack: 3, projectileCount: 3);
this.CreateSkill(SkillNumber.Heal, "Heal", CharacterClasses.FairyElf, distance: 6, manaConsumption: 20, energyRequirement: 52, skillType: SkillType.Regeneration, targetRestriction: SkillTargetRestriction.Player);
this.CreateSkill(SkillNumber.GreaterDefense, "Greater Defense", CharacterClasses.FairyElf, distance: 6, manaConsumption: 30, energyRequirement: 72, skillType: SkillType.Buff, targetRestriction: SkillTargetRestriction.Player);
this.CreateSkill(SkillNumber.GreaterDamage, "Greater Damage", CharacterClasses.FairyElf, distance: 6, manaConsumption: 40, energyRequirement: 92, skillType: SkillType.Buff, targetRestriction: SkillTargetRestriction.Player);
diff --git a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
index c7d4f5390..5e9b819b9 100644
--- a/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
+++ b/src/Persistence/Initialization/VersionSeasonSix/SkillsInitializer.cs
@@ -124,7 +124,7 @@ public override void Initialize()
this.CreateSkill(SkillNumber.Cyclone, "Cyclone", CharacterClasses.AllKnightsLordsAndMGs, DamageType.Physical, distance: 2, manaConsumption: 9, movesToTarget: true, movesTarget: true);
this.CreateSkill(SkillNumber.Slash, "Slash", CharacterClasses.AllKnightsLordsAndMGs, DamageType.Physical, distance: 2, manaConsumption: 10, movesToTarget: true, movesTarget: true);
this.CreateSkill(SkillNumber.TripleShot, "Triple Shot", CharacterClasses.AllElfs, DamageType.Physical, distance: 6, manaConsumption: 5, skillType: SkillType.AreaSkillAutomaticHits);
- this.AddAreaSkillSettings(SkillNumber.TripleShot, true, 1f, 4.5f, 7f, true, TimeSpan.FromMilliseconds(50), maximumHitsPerTarget: 3, maximumHitsPerAttack: 3);
+ this.AddAreaSkillSettings(SkillNumber.TripleShot, true, 1f, 4.5f, 7f, true, TimeSpan.FromMilliseconds(50), maximumHitsPerTarget: 3, maximumHitsPerAttack: 3, projectileCount: 3);
this.CreateSkill(SkillNumber.Heal, "Heal", CharacterClasses.AllElfs, distance: 6, manaConsumption: 20, energyRequirement: 52, skillType: SkillType.Regeneration, targetRestriction: SkillTargetRestriction.Player);
this.CreateSkill(SkillNumber.GreaterDefense, "Greater Defense", CharacterClasses.AllElfs, distance: 6, manaConsumption: 30, energyRequirement: 72, skillType: SkillType.Buff, targetRestriction: SkillTargetRestriction.Player);
this.CreateSkill(SkillNumber.GreaterDamage, "Greater Damage", CharacterClasses.AllElfs, distance: 6, manaConsumption: 40, energyRequirement: 92, skillType: SkillType.Buff, targetRestriction: SkillTargetRestriction.Player);
From a4e57be093ec2120e276313c0b71d95919d8f35b Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 1 Jan 2026 19:01:53 +0000
Subject: [PATCH 03/10] Fix projectile targeting logic and add comprehensive
tests
- Fixed CalculateRelativePositionInFrustum to properly calculate lateral position
- Added unit tests for FrustumBasedTargetFilter with multiple projectiles
- All tests passing for single and triple projectile scenarios
Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com>
---
.../Skills/FrustumBasedTargetFilter.cs | 116 +++++++-------
.../FrustumBasedTargetFilterTest.cs | 144 ++++++++++++++++++
2 files changed, 199 insertions(+), 61 deletions(-)
create mode 100644 tests/MUnique.OpenMU.Tests/FrustumBasedTargetFilterTest.cs
diff --git a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
index 7ae277f26..5288e9c9e 100644
--- a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
+++ b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
@@ -85,19 +85,37 @@ public IReadOnlyList GetProjectilesThatCanHitTarget(ILocateable attacker, I
return this.IsTargetWithinBounds(attacker, target, rotation) ? [0] : [];
}
+ // First check if target is within the overall frustum
+ if (!this.IsTargetWithinBounds(attacker, target, rotation))
+ {
+ return [];
+ }
+
var result = new List();
- // Calculate the angle span of the frustum
- var frustumAngleSpan = CalculateFrustumAngleSpan(this.StartWidth, this.EndWidth, this.Distance);
+ // Divide the frustum width into sections for each projectile
+ // Calculate which section(s) the target falls into
+ var frustum = this.GetFrustum(attacker.Position, rotation);
+
+ // Calculate the relative position of the target within the frustum
+ // Using a simple approach: determine the horizontal offset from the center line
+ var relativePosition = CalculateRelativePositionInFrustum(attacker.Position, target.Position, rotation);
+
+ // Divide the frustum into sections (-1 to 1 range)
+ // For 3 projectiles: left (-1 to -0.33), center (-0.33 to 0.33), right (0.33 to 1)
+ var sectionWidth = 2.0 / this.ProjectileCount;
- // Distribute projectiles evenly within the frustum
for (int i = 0; i < this.ProjectileCount; i++)
{
- // Calculate the angle offset for this projectile
- // Projectiles are evenly distributed, e.g., for 3 projectiles: -1/2, 0, +1/2 of the span
- var angleOffset = (i - (this.ProjectileCount - 1) / 2.0) * frustumAngleSpan / (this.ProjectileCount - 1);
+ var sectionStart = -1.0 + (i * sectionWidth);
+ var sectionEnd = sectionStart + sectionWidth;
+
+ // Add some overlap so targets near boundaries can be hit by adjacent projectiles
+ const double overlap = 0.15;
+ sectionStart -= overlap;
+ sectionEnd += overlap;
- if (this.IsTargetWithinProjectilePath(attacker, target, rotation, angleOffset))
+ if (relativePosition >= sectionStart && relativePosition <= sectionEnd)
{
result.Add(i);
}
@@ -106,67 +124,43 @@ public IReadOnlyList GetProjectilesThatCanHitTarget(ILocateable attacker, I
return result;
}
- private static double CalculateFrustumAngleSpan(float startWidth, float endWidth, float distance)
- {
- // Calculate the angle span based on the frustum dimensions
- // Use the larger width to get the full span
- var maxWidth = Math.Max(startWidth, endWidth);
- return Math.Atan2(maxWidth, distance) * 2.0;
- }
-
- private bool IsTargetWithinProjectilePath(ILocateable attacker, ILocateable target, byte rotation, double angleOffset)
+ private double CalculateRelativePositionInFrustum(Point attackerPos, Point targetPos, byte rotation)
{
- // Create a narrower frustum for this specific projectile
- // The projectile has a narrow cone around its path
- var projectileWidth = Math.Max(this.StartWidth / this.ProjectileCount, 0.5f);
- var projectileEndWidth = Math.Max(this.EndWidth / this.ProjectileCount, 0.5f);
+ // Calculate the vector from attacker to target
+ var dx = targetPos.X - attackerPos.X;
+ var dy = targetPos.Y - attackerPos.Y;
- // Calculate the center direction of this projectile
- var frustum = this.GetProjectileFrustum(attacker.Position, rotation, angleOffset, projectileWidth, projectileEndWidth);
- return IsWithinFrustum(frustum, target.Position);
- }
-
- private (Vector4 X, Vector4 Y) GetProjectileFrustum(Point attackerPosition, byte rotation, double angleOffset, float width, float endWidth)
- {
- const int degreeOffset = 180;
- const float distanceOffset = 0.99f;
-
- // Calculate the rotation with the projectile offset
- var baseRotation = (rotation * 360.0) / byte.MaxValue;
- baseRotation = (baseRotation + degreeOffset) % 360;
- var offsetDegrees = angleOffset * (180.0 / Math.PI); // Convert radians to degrees
- var totalDegrees = baseRotation + offsetDegrees;
+ // Calculate the rotation angle (same logic as in CalculateRotationVectors)
+ var rotationAngle = (rotation * 360.0 / 256.0) + 180; // Add 180 offset as in the frustum calculation
+ var rotationRad = rotationAngle * Math.PI / 180.0;
- var angleMatrix = CreateAngleMatrix(totalDegrees);
+ // Rotate the vector to align with the frustum's coordinate system
+ // The frustum has Y pointing forward and X pointing right
+ var cos = Math.Cos(-rotationRad);
+ var sin = Math.Sin(-rotationRad);
+ var rotatedX = dx * cos - dy * sin;
+ var rotatedY = dx * sin + dy * cos;
- // Define the frustum corners for this projectile
- var temp = new Vector3[4];
- temp[0] = new Vector3(-endWidth, this.Distance, 0);
- temp[1] = new Vector3(endWidth, this.Distance, 0);
- temp[2] = new Vector3(width, distanceOffset, 0);
- temp[3] = new Vector3(-width, distanceOffset, 0);
+ // If target is behind us or too close, return 0
+ if (rotatedY <= 0)
+ {
+ return 0;
+ }
- var rotationVectors = new Vector2[4];
- for (int i = 0; i < temp.Length; i++)
+ // Calculate the frustum width at the target's distance
+ // Linear interpolation between start and end width
+ var distanceRatio = Math.Min(rotatedY / this.Distance, 1.0);
+ var frustumWidthAtDistance = this.StartWidth + (this.EndWidth - this.StartWidth) * distanceRatio;
+
+ // Normalize the X position by the frustum width at that distance
+ // Result will be in range [-1, 1] where -1 is left edge, 0 is center, 1 is right edge
+ if (Math.Abs(frustumWidthAtDistance) < 0.001)
{
- rotationVectors[i] = VectorRotate(temp[i], angleMatrix);
+ return 0;
}
-
- Vector4 resultX = default;
- Vector4 resultY = default;
- resultX.X = (int)rotationVectors[0].X + attackerPosition.X;
- resultY.X = (int)rotationVectors[0].Y + attackerPosition.Y;
-
- resultX.Y = (int)rotationVectors[1].X + attackerPosition.X;
- resultY.Y = (int)rotationVectors[1].Y + attackerPosition.Y;
-
- resultX.Z = (int)rotationVectors[2].X + attackerPosition.X;
- resultY.Z = (int)rotationVectors[2].Y + attackerPosition.Y;
-
- resultX.W = (int)rotationVectors[3].X + attackerPosition.X;
- resultY.W = (int)rotationVectors[3].Y + attackerPosition.Y;
-
- return (resultX, resultY);
+
+ var normalizedX = rotatedX / frustumWidthAtDistance;
+ return Math.Clamp(normalizedX, -1.0, 1.0);
}
private static bool IsWithinFrustum((Vector4 X, Vector4 Y) frustum, Point target)
diff --git a/tests/MUnique.OpenMU.Tests/FrustumBasedTargetFilterTest.cs b/tests/MUnique.OpenMU.Tests/FrustumBasedTargetFilterTest.cs
new file mode 100644
index 000000000..1b7127c31
--- /dev/null
+++ b/tests/MUnique.OpenMU.Tests/FrustumBasedTargetFilterTest.cs
@@ -0,0 +1,144 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Tests;
+
+using Moq;
+using MUnique.OpenMU.GameLogic;
+using MUnique.OpenMU.GameLogic.PlayerActions.Skills;
+using MUnique.OpenMU.Pathfinding;
+
+///
+/// Tests for the .
+///
+[TestFixture]
+internal class FrustumBasedTargetFilterTest
+{
+ ///
+ /// Tests that a single projectile can hit a target in the center of the frustum.
+ ///
+ [Test]
+ public void SingleProjectile_TargetInCenter_CanHit()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 1);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(100, 105); // Directly in front (positive Y)
+
+ // Rotation 128 points in +Y direction (180 degrees in 0-255 system)
+ var result = filter.GetProjectilesThatCanHitTarget(attacker, target, 128);
+
+ Assert.That(result, Has.Count.EqualTo(1));
+ Assert.That(result[0], Is.EqualTo(0));
+ }
+
+ ///
+ /// Tests that with triple shot, a target in the center can be hit by all three projectiles.
+ ///
+ [Test]
+ public void TripleShot_TargetInCenter_CanBeHitByAllThree()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(100, 105); // Directly in front (positive Y)
+
+ // Rotation 128 points in +Y direction
+ var result = filter.GetProjectilesThatCanHitTarget(attacker, target, 128);
+
+ // Target in the center should be hittable by all 3 projectiles
+ Assert.That(result, Has.Count.GreaterThanOrEqualTo(1));
+ }
+
+ ///
+ /// Tests that with triple shot, a target on the left side can only be hit by the left projectile.
+ ///
+ [Test]
+ public void TripleShot_TargetOnLeft_CanBeHitByLeftProjectile()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(98, 105); // To the left and in front (2 units left, within frustum)
+
+ // Rotation 128 points in +Y direction
+ var result = filter.GetProjectilesThatCanHitTarget(attacker, target, 128);
+
+ // Target on the left should be hittable by at least one projectile (the left one)
+ Assert.That(result, Has.Count.GreaterThanOrEqualTo(1));
+ // But not by all three
+ Assert.That(result, Has.Count.LessThanOrEqualTo(2));
+ }
+
+ ///
+ /// Tests that with triple shot, a target on the right side can only be hit by the right projectile.
+ ///
+ [Test]
+ public void TripleShot_TargetOnRight_CanBeHitByRightProjectile()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(102, 105); // To the right and in front (2 units right, within frustum)
+
+ // Rotation 128 points in +Y direction
+ var result = filter.GetProjectilesThatCanHitTarget(attacker, target, 128);
+
+ // Target on the right should be hittable by at least one projectile (the right one)
+ Assert.That(result, Has.Count.GreaterThanOrEqualTo(1));
+ // But not by all three
+ Assert.That(result, Has.Count.LessThanOrEqualTo(2));
+ }
+
+ ///
+ /// Tests that a target outside the frustum cannot be hit by any projectile.
+ ///
+ [Test]
+ public void TripleShot_TargetOutsideFrustum_CannotBeHit()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(110, 105); // Far to the right, outside frustum
+
+ // Rotation 128 points in +Y direction
+ var result = filter.GetProjectilesThatCanHitTarget(attacker, target, 128);
+
+ Assert.That(result, Is.Empty);
+ }
+
+ ///
+ /// Tests that the old IsTargetWithinBounds method still works for backward compatibility.
+ ///
+ [Test]
+ public void IsTargetWithinBounds_TargetInFrustum_ReturnsTrue()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(100, 105); // Directly in front
+
+ // Rotation 128 points in +Y direction
+ var result = filter.IsTargetWithinBounds(attacker, target, 128);
+
+ Assert.That(result, Is.True);
+ }
+
+ ///
+ /// Tests that the old IsTargetWithinBounds method returns false for targets outside the frustum.
+ ///
+ [Test]
+ public void IsTargetWithinBounds_TargetOutsideFrustum_ReturnsFalse()
+ {
+ var filter = new FrustumBasedTargetFilter(1f, 4.5f, 7f, 3);
+ var attacker = CreateLocateable(100, 100);
+ var target = CreateLocateable(110, 105); // Far to the right, outside frustum
+
+ // Rotation 128 points in +Y direction
+ var result = filter.IsTargetWithinBounds(attacker, target, 128);
+
+ Assert.That(result, Is.False);
+ }
+
+ private static ILocateable CreateLocateable(byte x, byte y)
+ {
+ var mock = new Mock();
+ mock.Setup(l => l.Position).Returns(new Point(x, y));
+ return mock.Object;
+ }
+}
From 264f75d0bb1c04267723ec00b77c6881c2f91d25 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Thu, 1 Jan 2026 19:05:10 +0000
Subject: [PATCH 04/10] Address code review feedback: replace magic numbers
with named constants
- Added DistanceEpsilon and ProjectileOverlap constants
- Removed unused frustum variable calculation
- All tests still passing
Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com>
---
.../Skills/FrustumBasedTargetFilter.cs | 15 ++++++---------
1 file changed, 6 insertions(+), 9 deletions(-)
diff --git a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
index 5288e9c9e..69973a5d1 100644
--- a/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
+++ b/src/GameLogic/PlayerActions/Skills/FrustumBasedTargetFilter.cs
@@ -13,6 +13,8 @@ namespace MUnique.OpenMU.GameLogic.PlayerActions.Skills;
///
public record FrustumBasedTargetFilter
{
+ private const double DistanceEpsilon = 0.001;
+ private const double ProjectileOverlap = 0.15; // Overlap between projectile zones to handle edge cases
private readonly Vector2[][] _rotationVectors;
///
@@ -93,10 +95,6 @@ public IReadOnlyList GetProjectilesThatCanHitTarget(ILocateable attacker, I
var result = new List();
- // Divide the frustum width into sections for each projectile
- // Calculate which section(s) the target falls into
- var frustum = this.GetFrustum(attacker.Position, rotation);
-
// Calculate the relative position of the target within the frustum
// Using a simple approach: determine the horizontal offset from the center line
var relativePosition = CalculateRelativePositionInFrustum(attacker.Position, target.Position, rotation);
@@ -110,10 +108,9 @@ public IReadOnlyList GetProjectilesThatCanHitTarget(ILocateable attacker, I
var sectionStart = -1.0 + (i * sectionWidth);
var sectionEnd = sectionStart + sectionWidth;
- // Add some overlap so targets near boundaries can be hit by adjacent projectiles
- const double overlap = 0.15;
- sectionStart -= overlap;
- sectionEnd += overlap;
+ // Add overlap so targets near boundaries can be hit by adjacent projectiles
+ sectionStart -= ProjectileOverlap;
+ sectionEnd += ProjectileOverlap;
if (relativePosition >= sectionStart && relativePosition <= sectionEnd)
{
@@ -154,7 +151,7 @@ private double CalculateRelativePositionInFrustum(Point attackerPos, Point targe
// Normalize the X position by the frustum width at that distance
// Result will be in range [-1, 1] where -1 is left edge, 0 is center, 1 is right edge
- if (Math.Abs(frustumWidthAtDistance) < 0.001)
+ if (Math.Abs(frustumWidthAtDistance) < DistanceEpsilon)
{
return 0;
}
From 3bf2b0c87077d44c0264acec251386ce68dc23e8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Sat, 3 Jan 2026 17:37:20 +0000
Subject: [PATCH 05/10] Add EF Core migration for ProjectileCount and optimize
memory usage
- Added migration 20260103173108_AddProjectileCountToAreaSkillSettings
- Updated EntityDataContextModelSnapshot with ProjectileCount property
- Optimized AttackTargetsAsync to use Dictionary instead of Dictionary>
- Reduced memory allocations by storing projectile counts instead of lists
- All tests passing
Co-authored-by: sven-n <5238610+sven-n@users.noreply.github.com>
---
.../Skills/AreaSkillAttackAction.cs | 19 +-
...ectileCountToAreaSkillSettings.Designer.cs | 5179 +++++++++++++++++
...8_AddProjectileCountToAreaSkillSettings.cs | 31 +
.../EntityDataContextModelSnapshot.cs | 3 +
4 files changed, 5223 insertions(+), 9 deletions(-)
create mode 100644 src/Persistence/EntityFramework/Migrations/20260103173108_AddProjectileCountToAreaSkillSettings.Designer.cs
create mode 100644 src/Persistence/EntityFramework/Migrations/20260103173108_AddProjectileCountToAreaSkillSettings.cs
diff --git a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
index 4e6bd0a07..fc6cdfc2a 100644
--- a/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
+++ b/src/GameLogic/PlayerActions/Skills/AreaSkillAttackAction.cs
@@ -127,22 +127,23 @@ private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTar
var maxAttacks = areaSkillSettings.MaximumNumberOfHitsPerAttack == 0 ? int.MaxValue : areaSkillSettings.MaximumNumberOfHitsPerAttack;
var currentDelay = TimeSpan.Zero;
- // For skills with multiple projectiles, track which projectiles hit which targets
- Dictionary>? targetToProjectileMap = null;
+ // For skills with multiple projectiles, we need to track how many projectiles each target has remaining
+ // Using a dictionary with int counters instead of Lists to be more memory efficient
+ Dictionary? targetProjectileCount = null;
FrustumBasedTargetFilter? filter = null;
if (areaSkillSettings is { UseFrustumFilter: true, ProjectileCount: > 1 })
{
filter = FrustumFilters.GetOrAdd(areaSkillSettings, static s => new FrustumBasedTargetFilter(s.FrustumStartWidth, s.FrustumEndWidth, s.FrustumDistance, s.ProjectileCount));
- targetToProjectileMap = new Dictionary>();
+ targetProjectileCount = new Dictionary();
- // Determine which projectiles can hit each target
+ // Determine which projectiles can hit each target and store the count
foreach (var target in targets)
{
var projectiles = filter.GetProjectilesThatCanHitTarget(player, target, rotation);
if (projectiles.Count > 0)
{
- targetToProjectileMap[target] = new List(projectiles);
+ targetProjectileCount[target] = projectiles.Count;
}
}
}
@@ -167,15 +168,15 @@ private async ValueTask PerformAutomaticHitsAsync(Player player, ushort extraTar
}
// For multiple projectiles, check if there are any projectiles left that can hit this target
- if (targetToProjectileMap != null)
+ if (targetProjectileCount != null)
{
- if (!targetToProjectileMap.TryGetValue(target, out var availableProjectiles) || availableProjectiles.Count == 0)
+ if (!targetProjectileCount.TryGetValue(target, out var remainingProjectiles) || remainingProjectiles == 0)
{
continue; // No projectiles can hit this target
}
- // Remove one projectile from the available list (it's been used for this hit)
- availableProjectiles.RemoveAt(0);
+ // Decrement the projectile count for this target
+ targetProjectileCount[target] = remainingProjectiles - 1;
}
var hitChance = attackRound < areaSkillSettings.MinimumNumberOfHitsPerTarget
diff --git a/src/Persistence/EntityFramework/Migrations/20260103173108_AddProjectileCountToAreaSkillSettings.Designer.cs b/src/Persistence/EntityFramework/Migrations/20260103173108_AddProjectileCountToAreaSkillSettings.Designer.cs
new file mode 100644
index 000000000..988830b5a
--- /dev/null
+++ b/src/Persistence/EntityFramework/Migrations/20260103173108_AddProjectileCountToAreaSkillSettings.Designer.cs
@@ -0,0 +1,5179 @@
+//
+using System;
+using MUnique.OpenMU.Persistence.EntityFramework;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace MUnique.OpenMU.Persistence.EntityFramework.Migrations
+{
+ [DbContext(typeof(EntityDataContext))]
+ [Migration("20260103173108_AddProjectileCountToAreaSkillSettings")]
+ partial class AddProjectileCountToAreaSkillSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "9.0.0")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Account", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatBanUntil")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("EMail")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("IsTemplate")
+ .HasColumnType("boolean");
+
+ b.Property("IsVaultExtended")
+ .HasColumnType("boolean");
+
+ b.Property("LoginName")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("RegistrationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("SecurityCode")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("TimeZone")
+ .HasColumnType("smallint");
+
+ b.Property("VaultId")
+ .HasColumnType("uuid");
+
+ b.Property("VaultPassword")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("LoginName")
+ .IsUnique();
+
+ b.HasIndex("VaultId")
+ .IsUnique();
+
+ b.ToTable("Account", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AccountCharacterClass", b =>
+ {
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.HasKey("AccountId", "CharacterClassId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AccountCharacterClass", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AppearanceData", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("FullAncientSetEquipped")
+ .HasColumnType("boolean");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.ToTable("AppearanceData", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AreaSkillSettings", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DelayBetweenHits")
+ .HasColumnType("interval");
+
+ b.Property("DelayPerOneDistance")
+ .HasColumnType("interval");
+
+ b.Property("FrustumDistance")
+ .HasColumnType("real");
+
+ b.Property("FrustumEndWidth")
+ .HasColumnType("real");
+
+ b.Property("FrustumStartWidth")
+ .HasColumnType("real");
+
+ b.Property("HitChancePerDistanceMultiplier")
+ .HasColumnType("real");
+
+ b.Property("MaximumNumberOfHitsPerAttack")
+ .HasColumnType("integer");
+
+ b.Property("MaximumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("MinimumNumberOfHitsPerTarget")
+ .HasColumnType("integer");
+
+ b.Property("ProjectileCount")
+ .HasColumnType("integer");
+
+ b.Property("TargetAreaDiameter")
+ .HasColumnType("real");
+
+ b.Property("UseDeferredHits")
+ .HasColumnType("boolean");
+
+ b.Property("UseFrustumFilter")
+ .HasColumnType("boolean");
+
+ b.Property("UseTargetAreaFilter")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("AreaSkillSettings", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Designation")
+ .HasColumnType("text");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("MaximumValue")
+ .HasColumnType("real");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.ToTable("AttributeDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRelationship", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AggregateType")
+ .HasColumnType("integer");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("InputAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("InputOperand")
+ .HasColumnType("real");
+
+ b.Property("InputOperator")
+ .HasColumnType("integer");
+
+ b.Property("OperandAttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("PowerUpDefinitionValueId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("TargetAttributeId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("InputAttributeId");
+
+ b.HasIndex("OperandAttributeId");
+
+ b.HasIndex("PowerUpDefinitionValueId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("TargetAttributeId");
+
+ b.ToTable("AttributeRelationship", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.AttributeRequirement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AttributeId")
+ .HasColumnType("uuid");
+
+ b.Property("GameMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("ItemDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("MinimumValue")
+ .HasColumnType("integer");
+
+ b.Property("SkillId")
+ .HasColumnType("uuid");
+
+ b.Property("SkillId1")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AttributeId");
+
+ b.HasIndex("GameMapDefinitionId");
+
+ b.HasIndex("ItemDefinitionId");
+
+ b.HasIndex("SkillId");
+
+ b.HasIndex("SkillId1");
+
+ b.ToTable("AttributeRequirement", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.BattleZoneDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("GroundId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("LeftTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("LeftTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("RightGoalId")
+ .HasColumnType("uuid");
+
+ b.Property("RightTeamSpawnPointX")
+ .HasColumnType("smallint");
+
+ b.Property("RightTeamSpawnPointY")
+ .HasColumnType("smallint");
+
+ b.Property("Type")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GroundId")
+ .IsUnique();
+
+ b.HasIndex("LeftGoalId")
+ .IsUnique();
+
+ b.HasIndex("RightGoalId")
+ .IsUnique();
+
+ b.ToTable("BattleZoneDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Character", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AccountId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterSlot")
+ .HasColumnType("smallint");
+
+ b.Property("CharacterStatus")
+ .HasColumnType("integer");
+
+ b.Property("CreateDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CurrentMapId")
+ .HasColumnType("uuid");
+
+ b.Property("Experience")
+ .HasColumnType("bigint");
+
+ b.Property("InventoryExtensions")
+ .HasColumnType("integer");
+
+ b.Property("InventoryId")
+ .HasColumnType("uuid");
+
+ b.Property("IsStoreOpened")
+ .HasColumnType("boolean");
+
+ b.Property("KeyConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("LevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MasterExperience")
+ .HasColumnType("bigint");
+
+ b.Property("MasterLevelUpPoints")
+ .HasColumnType("integer");
+
+ b.Property("MuHelperConfiguration")
+ .HasColumnType("bytea");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)");
+
+ b.Property("PlayerKillCount")
+ .HasColumnType("integer");
+
+ b.Property("Pose")
+ .HasColumnType("smallint");
+
+ b.Property("PositionX")
+ .HasColumnType("smallint");
+
+ b.Property("PositionY")
+ .HasColumnType("smallint");
+
+ b.Property("State")
+ .HasColumnType("integer");
+
+ b.Property("StateRemainingSeconds")
+ .HasColumnType("integer");
+
+ b.Property("StoreName")
+ .HasColumnType("text");
+
+ b.Property("UsedFruitPoints")
+ .HasColumnType("integer");
+
+ b.Property("UsedNegFruitPoints")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccountId");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("CurrentMapId");
+
+ b.HasIndex("InventoryId")
+ .IsUnique();
+
+ b.HasIndex("Name")
+ .IsUnique();
+
+ b.ToTable("Character", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterClass", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CanGetCreated")
+ .HasColumnType("boolean");
+
+ b.Property("ComboDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("CreationAllowedFlag")
+ .HasColumnType("smallint");
+
+ b.Property("FruitCalculation")
+ .HasColumnType("integer");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("HomeMapId")
+ .HasColumnType("uuid");
+
+ b.Property("IsMasterClass")
+ .HasColumnType("boolean");
+
+ b.Property("LevelRequirementByCreation")
+ .HasColumnType("smallint");
+
+ b.Property("LevelWarpRequirementReductionPercent")
+ .HasColumnType("integer");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("NextGenerationClassId")
+ .HasColumnType("uuid");
+
+ b.Property("Number")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ComboDefinitionId")
+ .IsUnique();
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("HomeMapId");
+
+ b.HasIndex("NextGenerationClassId");
+
+ b.ToTable("CharacterClass", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterDropItemGroup", b =>
+ {
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("DropItemGroupId")
+ .HasColumnType("uuid");
+
+ b.HasKey("CharacterId", "DropItemGroupId");
+
+ b.HasIndex("DropItemGroupId");
+
+ b.ToTable("CharacterDropItemGroup", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CharacterQuestState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ActiveQuestId")
+ .HasColumnType("uuid");
+
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientActionPerformed")
+ .HasColumnType("boolean");
+
+ b.Property("Group")
+ .HasColumnType("smallint");
+
+ b.Property("LastFinishedQuestId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ActiveQuestId");
+
+ b.HasIndex("CharacterId");
+
+ b.HasIndex("LastFinishedQuestId");
+
+ b.ToTable("CharacterQuestState", "data");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ClientCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ClientTimeout")
+ .HasColumnType("interval");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("MaximumConnections")
+ .HasColumnType("integer");
+
+ b.Property("RoomCleanUpInterval")
+ .HasColumnType("interval");
+
+ b.Property("ServerId")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.ToTable("ChatServerDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ChatServerEndpoint", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ChatServerDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b.Property("NetworkPort")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ChatServerDefinitionId");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ChatServerEndpoint", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.CombinationBonusRequirement", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("ItemOptionCombinationBonusId")
+ .HasColumnType("uuid");
+
+ b.Property("MinimumCount")
+ .HasColumnType("integer");
+
+ b.Property("OptionTypeId")
+ .HasColumnType("uuid");
+
+ b.Property("SubOptionType")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemOptionCombinationBonusId");
+
+ b.HasIndex("OptionTypeId");
+
+ b.ToTable("CombinationBonusRequirement", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("InstalledAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Version")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("ConfigurationUpdate", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConfigurationUpdateState", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CurrentInstalledVersion")
+ .HasColumnType("integer");
+
+ b.Property("InitializationKey")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.ToTable("ConfigurationUpdateState", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConnectServerDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CheckMaxConnectionsPerAddress")
+ .HasColumnType("boolean");
+
+ b.Property("ClientId")
+ .HasColumnType("uuid");
+
+ b.Property("ClientListenerPort")
+ .HasColumnType("integer");
+
+ b.Property("CurrentPatchVersion")
+ .HasColumnType("bytea");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("DisconnectOnUnknownPacket")
+ .HasColumnType("boolean");
+
+ b.Property("ListenerBacklog")
+ .HasColumnType("integer");
+
+ b.Property("MaxConnections")
+ .HasColumnType("integer");
+
+ b.Property("MaxConnectionsPerAddress")
+ .HasColumnType("integer");
+
+ b.Property("MaxFtpRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaxIpRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaxServerListRequests")
+ .HasColumnType("integer");
+
+ b.Property("MaximumReceiveSize")
+ .HasColumnType("smallint");
+
+ b.Property("PatchAddress")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ServerId")
+ .HasColumnType("smallint");
+
+ b.Property("Timeout")
+ .HasColumnType("interval");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ConnectServerDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ConstValueAttribute", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("CharacterClassId")
+ .HasColumnType("uuid");
+
+ b.Property("DefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("Value")
+ .HasColumnType("real");
+
+ b.HasKey("Id");
+
+ b.HasIndex("CharacterClassId");
+
+ b.HasIndex("DefinitionId");
+
+ b.ToTable("ConstValueAttribute", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Chance")
+ .HasColumnType("double precision");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("GameConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("ItemLevel")
+ .HasColumnType("smallint");
+
+ b.Property("ItemType")
+ .HasColumnType("integer");
+
+ b.Property("MaximumMonsterLevel")
+ .HasColumnType("smallint");
+
+ b.Property("MinimumMonsterLevel")
+ .HasColumnType("smallint");
+
+ b.Property("MonsterId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameConfigurationId");
+
+ b.HasIndex("MonsterId");
+
+ b.ToTable("DropItemGroup", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DropItemGroupItemDefinition", b =>
+ {
+ b.Property("DropItemGroupId")
+ .HasColumnType("uuid");
+
+ b.Property("ItemDefinitionId")
+ .HasColumnType("uuid");
+
+ b.HasKey("DropItemGroupId", "ItemDefinitionId");
+
+ b.HasIndex("ItemDefinitionId");
+
+ b.ToTable("DropItemGroupItemDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelArea", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("DuelConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("FirstPlayerGateId")
+ .HasColumnType("uuid");
+
+ b.Property("Index")
+ .HasColumnType("smallint");
+
+ b.Property("SecondPlayerGateId")
+ .HasColumnType("uuid");
+
+ b.Property("SpectatorsGateId")
+ .HasColumnType("uuid");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DuelConfigurationId");
+
+ b.HasIndex("FirstPlayerGateId");
+
+ b.HasIndex("SecondPlayerGateId");
+
+ b.HasIndex("SpectatorsGateId");
+
+ b.ToTable("DuelArea", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.DuelConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("EntranceFee")
+ .HasColumnType("integer");
+
+ b.Property("ExitId")
+ .HasColumnType("uuid");
+
+ b.Property("MaximumScore")
+ .HasColumnType("integer");
+
+ b.Property("MaximumSpectatorsPerDuelRoom")
+ .HasColumnType("integer");
+
+ b.Property("MinimumCharacterLevel")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ExitId");
+
+ b.ToTable("DuelConfiguration", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.EnterGate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("GameMapDefinitionId")
+ .HasColumnType("uuid");
+
+ b.Property("LevelRequirement")
+ .HasColumnType("smallint");
+
+ b.Property("Number")
+ .HasColumnType("smallint");
+
+ b.Property("TargetGateId")
+ .HasColumnType("uuid");
+
+ b.Property("X1")
+ .HasColumnType("smallint");
+
+ b.Property("X2")
+ .HasColumnType("smallint");
+
+ b.Property("Y1")
+ .HasColumnType("smallint");
+
+ b.Property("Y2")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("GameMapDefinitionId");
+
+ b.HasIndex("TargetGateId");
+
+ b.ToTable("EnterGate", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.ExitGate", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Direction")
+ .HasColumnType("integer");
+
+ b.Property("IsSpawnGate")
+ .HasColumnType("boolean");
+
+ b.Property("MapId")
+ .HasColumnType("uuid");
+
+ b.Property("X1")
+ .HasColumnType("smallint");
+
+ b.Property("X2")
+ .HasColumnType("smallint");
+
+ b.Property("Y1")
+ .HasColumnType("smallint");
+
+ b.Property("Y2")
+ .HasColumnType("smallint");
+
+ b.HasKey("Id");
+
+ b.HasIndex("MapId");
+
+ b.ToTable("ExitGate", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.Friend", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Accepted")
+ .HasColumnType("boolean");
+
+ b.Property("CharacterId")
+ .HasColumnType("uuid");
+
+ b.Property("FriendId")
+ .HasColumnType("uuid");
+
+ b.Property("RequestOpen")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.HasAlternateKey("CharacterId", "FriendId");
+
+ b.ToTable("Friend", "friend");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameClientDefinition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("Episode")
+ .HasColumnType("smallint");
+
+ b.Property("Language")
+ .HasColumnType("integer");
+
+ b.Property("Season")
+ .HasColumnType("smallint");
+
+ b.Property("Serial")
+ .HasColumnType("bytea");
+
+ b.Property("Version")
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.ToTable("GameClientDefinition", "config");
+ });
+
+ modelBuilder.Entity("MUnique.OpenMU.Persistence.EntityFramework.Model.GameConfiguration", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("AreaSkillHitsPlayer")
+ .HasColumnType("boolean");
+
+ b.Property("CharacterNameRegex")
+ .HasColumnType("text");
+
+ b.Property("ClampMoneyOnPickup")
+ .HasColumnType("boolean");
+
+ b.Property("DamagePerOneItemDurability")
+ .HasColumnType("double precision");
+
+ b.Property("DamagePerOnePetDurability")
+ .HasColumnType("double precision");
+
+ b.Property("DuelConfigurationId")
+ .HasColumnType("uuid");
+
+ b.Property("ExperienceFormula")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("if(level == 0, 0, if(level < 256, 10 * (level + 8) * (level - 1) * (level - 1), (10 * (level + 8) * (level - 1) * (level - 1)) + (1000 * (level - 247) * (level - 256) * (level - 256))))");
+
+ b.Property("ExperienceRate")
+ .HasColumnType("real");
+
+ b.Property("HitsPerOneItemDurability")
+ .HasColumnType("double precision");
+
+ b.Property("InfoRange")
+ .HasColumnType("smallint");
+
+ b.Property("ItemDropDuration")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("interval")
+ .HasDefaultValue(new TimeSpan(0, 0, 1, 0, 0));
+
+ b.Property("LetterSendPrice")
+ .HasColumnType("integer");
+
+ b.Property("MasterExperienceFormula")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text")
+ .HasDefaultValue("(505 * level * level * level) + (35278500 * level) + (228045 * level * level)");
+
+ b.Property("MaximumCharactersPerAccount")
+ .HasColumnType("smallint");
+
+ b.Property("MaximumInventoryMoney")
+ .HasColumnType("integer");
+
+ b.Property("MaximumItemOptionLevelDrop")
+ .HasColumnType("smallint");
+
+ b.Property("MaximumLetters")
+ .HasColumnType("integer");
+
+ b.Property