Skip to content
This repository was archived by the owner on Sep 20, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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)..]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -727,7 +728,10 @@ int generation
);
}
var detailRows = new List<GameObject>();
if (ShouldRenderTargetDetails(group))
if (
ShouldRenderTargetDetails(group)
&& CombatImpactDetailPresentationPolicy.ShouldRenderEntityRows(group.Targets.Count)
)
{
foreach (var target in group.Targets)
{
Expand Down Expand Up @@ -773,18 +777,21 @@ int generation
CombatImpactMetricFormatter.IncomingGroup(group, IsChinese(), CriticalMarker())
);
var detailRows = new List<GameObject>();
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);
}
Expand Down Expand Up @@ -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
);
Expand Down Expand Up @@ -1266,6 +1282,7 @@ private static TMP_Text CloneText(
string content,
float fontScale,
float flexibleWidth = 0f,
float preferredWidth = -1f,
float minWidth = -1f
)
{
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
{
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
);
}
}
65 changes: 65 additions & 0 deletions tests/PostCombatImpact.Tests/CombatImpactEffectIconKeyTests.cs
Original file line number Diff line number Diff line change
@@ -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")
);
}
}
62 changes: 62 additions & 0 deletions tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, int>
{
[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()
{
Expand Down
Loading