diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactDetailPresentationPolicy.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactDetailPresentationPolicy.cs new file mode 100644 index 00000000..d05d0fd4 --- /dev/null +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactDetailPresentationPolicy.cs @@ -0,0 +1,10 @@ +#nullable enable +namespace BazaarPlusPlus.Game.PostCombatImpact.Ui; + +internal static class CombatImpactDetailPresentationPolicy +{ + internal const int MaximumDetailedEntityRows = 12; + + internal static bool ShouldRenderEntityRows(int rowCount) => + rowCount <= MaximumDetailedEntityRows; +} diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactEffectIconKey.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactEffectIconKey.cs new file mode 100644 index 00000000..d66e514a --- /dev/null +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactEffectIconKey.cs @@ -0,0 +1,65 @@ +#nullable enable +using BazaarPlusPlus.Game.PostCombatImpact.Data; + +namespace BazaarPlusPlus.Game.PostCombatImpact.Ui; + +internal static class CombatImpactEffectIconKey +{ + internal static string Resolve(CombatImpactKind kind, string nativeAttributeKey) + { + if (kind == CombatImpactKind.Destroy) + return "Destroy"; + + if (kind != CombatImpactKind.AttributeChange) + return nativeAttributeKey; + + var (baseAttributeKey, variant) = SplitAttributeKey(nativeAttributeKey); + return ResolveAttribute(baseAttributeKey, variant); + } + + internal static string ResolveAttribute(string key, string? variant) => + key switch + { + "EnchantTargets" when !string.IsNullOrWhiteSpace(variant) => variant, + "EnchantTargets" or "EnchantRemoveTargets" => "Enchant", + "ChargeAmount" or "ChargeTargets" or "PercentChargeReduction" => "Charge", + "Haste" or "HasteAmount" or "HasteTargets" or "PercentHasteReduction" => "Haste", + "Slow" or "SlowAmount" or "SlowTargets" or "PercentSlowReduction" => "Slow", + "Freeze" or "FreezeAmount" or "FreezeTargets" or "PercentFreezeReduction" => "Freeze", + "RepairTargets" => "Repair", + "TransformTargets" => "Transform", + "UpgradeTargets" => "Upgrade", + "Flying" or "FlyingTargets" => "Flying", + "Health" + or "HealthMax" + or "HealthMaxIncrease" + or "HealthMaxDecrease" + or "HealAmount" + or "HealCrit" => "Healing", + "HealthRegen" or "RegenApplyAmount" or "RegenRemoveAmount" or "RegenCrit" => "Regen", + "Rage" or "RageMax" or "RageApplyAmount" or "RageRemoveAmount" => "Rage", + "Tempo" + or "TempoCost" + or "TempoApplyAmount" + or "TempoRemoveAmount" + or "FlatTempoCostReduction" + or "PercentTempoCostReduction" + or "TempoGainCooldownMax" + or "FlatTempoGainCooldownReduction" + or "PercentTempoGainCooldownReduction" => "Tempo", + "Burn" or "BurnApplyAmount" or "BurnRemoveAmount" or "BurnCrit" => "Burn", + "Poison" or "PoisonApplyAmount" or "PoisonRemoveAmount" or "PoisonCrit" => "Poison", + "Shield" or "ShieldApplyAmount" or "ShieldRemoveAmount" or "ShieldCrit" => "Shield", + "DamageAmount" => "Damage", + "DamageCrit" => "CritChance", + "AmmoMax" => "Ammo", + "DestroyTargets" or "DestroyImmunity" => "Destroy", + _ => key, + }; + + internal static (string BaseKey, string? Variant) SplitAttributeKey(string key) + { + var separator = key.IndexOf(':'); + return separator < 0 ? (key, null) : (key[..separator], key[(separator + 1)..]); + } +} diff --git a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs index 1421da1a..005bccf7 100644 --- a/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs +++ b/src/BazaarPlusPlus/Patches/PostCombatImpact/NativePostCombatImpactTooltipView.cs @@ -43,6 +43,7 @@ internal sealed class NativePostCombatImpactTooltipView : IPostCombatImpactToolt private const float GroupSecondaryMetricFontScale = 0.62f; private const float GroupStackedMetricMinFontScale = 0.68f; private const float GroupStackedMetricOpticalOffset = 2f; + private const float GroupIconColumnPreferredWidth = 34f; private const float TargetNameFontScale = 0.875f; private const float TargetMetricFontScale = 0.875f; private const float MetricColumnMinWidth = 112f; @@ -727,7 +728,10 @@ int generation ); } var detailRows = new List(); - if (ShouldRenderTargetDetails(group)) + if ( + ShouldRenderTargetDetails(group) + && CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows(group.Targets.Count) + ) { foreach (var target in group.Targets) { @@ -773,18 +777,21 @@ int generation CombatImpactMetricFormatter.IncomingGroup(group, IsChinese(), CriticalMarker()) ); var detailRows = new List(); - foreach (var source in group.Sources) + if (CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows(group.Sources.Count)) { - detailRows.Add( - BuildEntityRow( - textTemplate, - groupRoot, - "ImpactSourceRow", - source.Entity, - CombatImpactMetricFormatter.IncomingSource(group, source, IsChinese()), - generation - ) - ); + foreach (var source in group.Sources) + { + detailRows.Add( + BuildEntityRow( + textTemplate, + groupRoot, + "ImpactSourceRow", + source.Entity, + CombatImpactMetricFormatter.IncomingSource(group, source, IsChinese()), + generation + ) + ); + } } return new ImpactContentBlock(groupRoot.gameObject, detailRows); } @@ -821,11 +828,20 @@ private void BuildGroupHeader( string.Empty, useNumberFont: false ) ?? string.Empty; - var labelContent = string.IsNullOrWhiteSpace(effectIcon) ? label : $"{effectIcon} {label}"; + var iconText = CloneText( + textTemplate, + header, + effectIcon, + GroupLabelFontScale, + preferredWidth: GroupIconColumnPreferredWidth, + minWidth: GroupIconColumnPreferredWidth + ); + iconText.alignment = TextAlignmentOptions.MidlineLeft; + var labelText = CloneText( textTemplate, header, - labelContent, + label, GroupLabelFontScale, flexibleWidth: 1f ); @@ -1266,6 +1282,7 @@ private static TMP_Text CloneText( string content, float fontScale, float flexibleWidth = 0f, + float preferredWidth = -1f, float minWidth = -1f ) { @@ -1302,7 +1319,7 @@ private static TMP_Text CloneText( ); } text.text = content; - AddLayout(text.gameObject, preferredHeight: -1f, flexibleWidth, minWidth: minWidth); + AddLayout(text.gameObject, preferredHeight: -1f, flexibleWidth, preferredWidth, minWidth); return text; } @@ -1423,12 +1440,16 @@ private static (string Label, string IconKey) ResolveEffect( bool hasMixedValueDirections ) { - var (baseAttributeKey, variant) = SplitAttributeKey(nativeAttributeKey); + var (baseAttributeKey, variant) = CombatImpactEffectIconKey.SplitAttributeKey( + nativeAttributeKey + ); var iconKey = kind switch { - CombatImpactKind.Destroy => "Destroy", - CombatImpactKind.AttributeChange => AttributeIconKey(baseAttributeKey, variant), - _ => nativeAttributeKey, + CombatImpactKind.AttributeChange => CombatImpactEffectIconKey.ResolveAttribute( + baseAttributeKey, + variant + ), + _ => CombatImpactEffectIconKey.Resolve(kind, nativeAttributeKey), }; var label = kind switch { @@ -1471,29 +1492,6 @@ bool hasMixedValueDirections return string.IsNullOrWhiteSpace(marker) ? null : marker; } - private static (string BaseKey, string? Variant) SplitAttributeKey(string key) - { - var separator = key.IndexOf(':'); - return separator < 0 ? (key, null) : (key[..separator], key[(separator + 1)..]); - } - - private static string AttributeIconKey(string key, string? variant) => - key switch - { - "EnchantTargets" when !string.IsNullOrWhiteSpace(variant) => variant, - "Health" or "HealthMax" or "HealAmount" => "HealAmount", - "HealthRegen" or "RegenRemoveAmount" => "RegenApplyAmount", - "Rage" or "RageMax" => "RageApplyAmount", - "Tempo" => "TempoApplyAmount", - "Burn" or "BurnRemoveAmount" => "BurnApplyAmount", - "Poison" or "PoisonRemoveAmount" => "PoisonApplyAmount", - "RageRemoveAmount" => "RageApplyAmount", - "Shield" or "ShieldRemoveAmount" => "ShieldApplyAmount", - "EnchantRemoveTargets" => "EnchantTargets", - "DamageCrit" => "CritChance", - _ => key, - }; - private static bool IsChinese() => L.CurrentLanguageCode.StartsWith("zh", StringComparison.OrdinalIgnoreCase); diff --git a/tests/PostCombatImpact.Tests/CombatImpactDetailPresentationPolicyTests.cs b/tests/PostCombatImpact.Tests/CombatImpactDetailPresentationPolicyTests.cs new file mode 100644 index 00000000..b414cd58 --- /dev/null +++ b/tests/PostCombatImpact.Tests/CombatImpactDetailPresentationPolicyTests.cs @@ -0,0 +1,28 @@ +using BazaarPlusPlus.Game.PostCombatImpact.Ui; +using Xunit; + +namespace PostCombatImpact.Tests; + +public sealed class CombatImpactDetailPresentationPolicyTests +{ + [Fact] + public void Entity_rows_are_rendered_for_compact_groups() + { + Assert.True(CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows(0)); + Assert.True( + CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows( + CombatImpactDetailPresentationPolicy.MaximumDetailedEntityRows + ) + ); + } + + [Fact] + public void Entity_rows_are_suppressed_for_oversized_groups() + { + Assert.False( + CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows( + CombatImpactDetailPresentationPolicy.MaximumDetailedEntityRows + 1 + ) + ); + } +} diff --git a/tests/PostCombatImpact.Tests/CombatImpactEffectIconKeyTests.cs b/tests/PostCombatImpact.Tests/CombatImpactEffectIconKeyTests.cs new file mode 100644 index 00000000..e72a6a37 --- /dev/null +++ b/tests/PostCombatImpact.Tests/CombatImpactEffectIconKeyTests.cs @@ -0,0 +1,65 @@ +using BazaarPlusPlus.Game.PostCombatImpact.Data; +using BazaarPlusPlus.Game.PostCombatImpact.Ui; +using Xunit; + +namespace PostCombatImpact.Tests; + +public sealed class CombatImpactEffectIconKeyTests +{ + [Theory] + [InlineData("ChargeAmount", "Charge")] + [InlineData("ChargeTargets", "Charge")] + [InlineData("PercentChargeReduction", "Charge")] + [InlineData("HasteAmount", "Haste")] + [InlineData("HasteTargets", "Haste")] + [InlineData("PercentHasteReduction", "Haste")] + [InlineData("SlowAmount", "Slow")] + [InlineData("SlowTargets", "Slow")] + [InlineData("PercentSlowReduction", "Slow")] + [InlineData("FreezeAmount", "Freeze")] + [InlineData("FreezeTargets", "Freeze")] + [InlineData("PercentFreezeReduction", "Freeze")] + [InlineData("RepairTargets", "Repair")] + [InlineData("TransformTargets", "Transform")] + [InlineData("UpgradeTargets", "Upgrade")] + [InlineData("FlyingTargets", "Flying")] + [InlineData("EnchantTargets", "Enchant")] + [InlineData("EnchantRemoveTargets", "Enchant")] + [InlineData("DestroyImmunity", "Destroy")] + [InlineData("DamageAmount", "Damage")] + [InlineData("BurnRemoveAmount", "Burn")] + [InlineData("PoisonRemoveAmount", "Poison")] + [InlineData("RegenRemoveAmount", "Regen")] + [InlineData("ShieldRemoveAmount", "Shield")] + [InlineData("HealthMaxIncrease", "Healing")] + [InlineData("RageRemoveAmount", "Rage")] + [InlineData("TempoCost", "Tempo")] + [InlineData("FlatTempoCostReduction", "Tempo")] + [InlineData("PercentTempoCostReduction", "Tempo")] + [InlineData("TempoGainCooldownMax", "Tempo")] + public void Attribute_keys_use_native_keyword_icon_keys(string attributeKey, string iconKey) + { + Assert.Equal(iconKey, CombatImpactEffectIconKey.ResolveAttribute(attributeKey, null)); + } + + [Fact] + public void Enchant_variant_uses_variant_icon_key() + { + Assert.Equal( + "Fiery", + CombatImpactEffectIconKey.Resolve( + CombatImpactKind.AttributeChange, + "EnchantTargets:Fiery" + ) + ); + } + + [Fact] + public void Destroy_kind_uses_destroy_icon_key() + { + Assert.Equal( + "Destroy", + CombatImpactEffectIconKey.Resolve(CombatImpactKind.Destroy, "DestroyTargets") + ); + } +} diff --git a/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs b/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs index e44be293..1365809f 100644 --- a/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs +++ b/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs @@ -1677,6 +1677,68 @@ public void Same_frame_effect_partitions_collapse_to_one_trigger_without_losing_ Assert.Equal(1, Assert.Single(slow.TriggerSources).Count); } + [Fact] + public void Repair_destroy_transform_battles_can_create_wide_unique_target_groups() + { + const int useCount = 45; + var simulation = new CombatSim(); + simulation.CardStats["source"] = new Dictionary + { + [ECardStats.UseCount] = useCount, + }; + var entities = EntitiesWithSourceAttribute(ECardAttributeType.HasteAmount, 1_000) + .ToDictionary(item => item.Key, item => item.Value); + + for (var index = 0; index < useCount; index++) + { + var targetId = $"drone-{index}"; + entities[targetId] = new CombatImpactEntity( + targetId, + $"Small Drone {index}", + "Item", + null, + index + 10 + ); + simulation + .Frames[0] + .Events.Add( + Executed("source", EActionCommandType.CardDestroy, CardTarget(targetId)) + ); + simulation + .Frames[0] + .Events.Add( + Executed( + "source", + EActionCommandType.CardTransformDestroyed, + CardTarget(targetId) + ) + ); + simulation + .Frames[0] + .Events.Add( + Executed("source", EActionCommandType.CardRepair, CardTarget(targetId)) + ); + simulation + .Frames[0] + .Events.Add(Executed("source", EActionCommandType.CardHaste, CardTarget(targetId))); + } + + var source = Assert.Single(CombatImpactProjector.Project(simulation, entities).Sources); + var groups = source.Groups.ToDictionary(group => group.NativeAttributeKey); + + Assert.Equal(useCount, source.UseCount); + Assert.Equal( + useCount, + groups[CombatImpactAggregator.NativeKey(CombatImpactKind.Destroy)].Targets.Count + ); + Assert.Equal(useCount, groups["TransformTargets"].Targets.Count); + Assert.Equal(useCount, groups["RepairTargets"].Targets.Count); + Assert.Equal( + useCount, + groups[CombatImpactAggregator.NativeKey(CombatImpactKind.Haste)].Targets.Count + ); + } + [Fact] public void Trigger_sources_are_attached_to_the_group_they_caused() { diff --git a/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj b/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj index d4e7bd63..79cdd783 100644 --- a/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj +++ b/tests/PostCombatImpact.Tests/PostCombatImpact.Tests.csproj @@ -60,9 +60,17 @@ Include="../../src/BazaarPlusPlus/Game/PostCombatImpact/Ui/CombatImpactAttributeLabel.cs" Link="CombatImpactAttributeLabel.cs" /> + +