From b87111a78dee959160b4147e89cd5eac521f7d5c Mon Sep 17 00:00:00 2001 From: Peng Xiao Date: Thu, 6 Aug 2026 15:35:38 +0800 Subject: [PATCH] fix(combat-insights): attribute F Minor tempo --- .../Data/CombatImpactEntitySnapshotReader.cs | 4 +- .../Data/CombatImpactModels.cs | 4 +- .../Data/CombatImpactProjector.cs | 83 ++++++++++++++++++ .../CombatImpactProjectorTests.cs | 86 +++++++++++++++++++ 4 files changed, 175 insertions(+), 2 deletions(-) diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactEntitySnapshotReader.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactEntitySnapshotReader.cs index f333d6bb..33e174fc 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactEntitySnapshotReader.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactEntitySnapshotReader.cs @@ -68,7 +68,9 @@ internal static IReadOnlyDictionary Read() card.Owner?.CombatantId, effectAttributes.Abilities, effectAttributes.Auras, - effectAttributes.ReferenceValuedAuraEffectIds + effectAttributes.ReferenceValuedAuraEffectIds, + card.LeftSocketId, + card.HiddenTags == null ? null : card.HiddenTags.ToArray() ); } diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs index decfdbc2..1a97601d 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactModels.cs @@ -99,7 +99,9 @@ internal sealed record CombatImpactEntity( ECombatantId? CombatantId = null, IReadOnlyDictionary? AbilityAttributeTypesByEffectId = null, IReadOnlyDictionary? AuraAttributeTypesByEffectId = null, - IReadOnlyCollection? ReferenceValuedAuraEffectIds = null + IReadOnlyCollection? ReferenceValuedAuraEffectIds = null, + EContainerSocketId? SocketId = null, + IReadOnlyCollection? HiddenTags = null ); internal sealed record CombatImpactEvent( diff --git a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs index a04b948f..e2baeea9 100644 --- a/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs +++ b/src/BazaarPlusPlus/Game/PostCombatImpact/Data/CombatImpactProjector.cs @@ -10,6 +10,13 @@ namespace BazaarPlusPlus.Game.PostCombatImpact.Data; internal static class CombatImpactProjector { + private static readonly Guid FMinorTemplateId = Guid.Parse( + "37251594-5ff0-4604-804e-7259ee666f60" + ); + private static readonly Guid FNoteSocketEffectTemplateId = Guid.Parse( + "04eca54a-69bf-4874-8b6d-56d284bb58be" + ); + internal static CombatImpactReport Project( CombatSim simulation, IReadOnlyDictionary entities @@ -162,6 +169,7 @@ IReadOnlyDictionary entities authoritative[sourceId] = metrics; } + AddFMinorTempoEvents(entities, useCounts, events); RecoverDroppedAppliedEffectCriticals(events, authoritative); var report = CombatImpactAggregator.Aggregate( @@ -174,6 +182,81 @@ IReadOnlyDictionary entities return AttachTriggerSources(report, triggerOccurrences, entities); } + private static void AddFMinorTempoEvents( + IReadOnlyDictionary entities, + IReadOnlyDictionary useCounts, + ICollection events + ) + { + foreach ( + var skill in entities.Values.Where(entity => + entity.TemplateId == FMinorTemplateId + && entity.TypeLabel == "Skill" + && entity.CombatantId.HasValue + && entity.Attributes?.TryGetValue(ECardAttributeType.Custom_0, out var amount) + == true + && amount > 0 + ) + ) + { + var combatantId = skill.CombatantId!.Value; + var noteSockets = entities + .Values.Where(entity => + entity.TemplateId == FNoteSocketEffectTemplateId + && entity.CombatantId == combatantId + && entity.SocketId.HasValue + ) + .Select(entity => entity.SocketId!.Value) + .ToHashSet(); + if (noteSockets.Count == 0) + continue; + + foreach ( + var item in entities.Values.Where(entity => + entity.TypeLabel == "Item" + && entity.CombatantId == combatantId + && entity.SocketId.HasValue + && entity.HiddenTags?.Contains(EHiddenTag.Haste) == true + && OccupiesAnySocket(entity, noteSockets) + && useCounts.TryGetValue(entity.Id, out var uses) + && uses > 0 + ) + ) + { + var amount = skill.Attributes![ECardAttributeType.Custom_0]; + var uses = useCounts[item.Id]; + for (var index = 0; index < uses; index++) + { + events.Add( + new CombatImpactEvent( + CombatImpactKind.AttributeChange, + skill.Id, + PlayerId(combatantId), + amount, + CombatImpactValueUnit.Amount, + "TempoApplyAmount", + ValueBasis: CombatImpactValueBasis.ConfiguredActionAmount + ) + { + Surface = CombatImpactEventSurface.PlayerAttribute, + OccurrenceBasis = CombatImpactOccurrenceBasis.ReconstructedTransition, + } + ); + } + } + } + } + + private static bool OccupiesAnySocket( + CombatImpactEntity item, + IReadOnlyCollection sockets + ) + { + var start = (int)item.SocketId!.Value; + var end = start + Math.Max(1, item.DisplaySpan); + return sockets.Any(socket => (int)socket >= start && (int)socket < end); + } + private static CombatImpactReport AttachPeriodicImpacts( CombatImpactReport report, IReadOnlyDictionary impacts diff --git a/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs b/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs index 8512cd55..e44be293 100644 --- a/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs +++ b/tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs @@ -10,6 +10,92 @@ namespace PostCombatImpact.Tests; public sealed class CombatImpactProjectorTests { + [Fact] + public void F_minor_reconstructs_tempo_from_haste_item_uses_on_the_f_note() + { + var simulation = new CombatSim(); + simulation.CardStats["turner"] = new Dictionary + { + [ECardStats.UseCount] = 7, + }; + simulation.CardStats["non-haste-on-note"] = new Dictionary + { + [ECardStats.UseCount] = 20, + }; + simulation.CardStats["haste-off-note"] = new Dictionary + { + [ECardStats.UseCount] = 30, + }; + var entities = Entities().ToDictionary(item => item.Key, item => item.Value); + entities["f-minor"] = new CombatImpactEntity( + "f-minor", + "F Minor", + "Skill", + null, + 5, + Guid.Parse("37251594-5ff0-4604-804e-7259ee666f60"), + ETier.Gold, + Attributes: new Dictionary + { + [ECardAttributeType.Custom_0] = 2, + }, + CombatantId: ECombatantId.Player + ); + entities["f-note"] = new CombatImpactEntity( + "f-note", + "[F Note] Socket Effect", + "SocketEffect", + null, + 6, + Guid.Parse("04eca54a-69bf-4874-8b6d-56d284bb58be"), + CombatantId: ECombatantId.Player, + SocketId: EContainerSocketId.Socket_6 + ); + entities["turner"] = new CombatImpactEntity( + "turner", + "Turner", + "Item", + null, + 7, + DisplaySpan: 2, + CombatantId: ECombatantId.Player, + SocketId: EContainerSocketId.Socket_5, + HiddenTags: new[] { EHiddenTag.Haste } + ); + entities["non-haste-on-note"] = new CombatImpactEntity( + "non-haste-on-note", + "Non-Haste Item", + "Item", + null, + 8, + CombatantId: ECombatantId.Player, + SocketId: EContainerSocketId.Socket_6 + ); + entities["haste-off-note"] = new CombatImpactEntity( + "haste-off-note", + "Off-Note Haste Item", + "Item", + null, + 9, + CombatantId: ECombatantId.Player, + SocketId: EContainerSocketId.Socket_7, + HiddenTags: new[] { EHiddenTag.Haste } + ); + + var source = Assert.Single( + CombatImpactProjector.Project(simulation, entities).Sources, + candidate => candidate.Entity.Id == "f-minor" + ); + var tempo = Assert.Single(source.Groups); + + Assert.Equal(7, source.EffectCount); + Assert.Equal(7, tempo.Count); + Assert.Equal(14, tempo.ObservedValue); + Assert.Equal("TempoApplyAmount", tempo.NativeAttributeKey); + Assert.Equal(CombatImpactEventSurface.PlayerAttribute, tempo.Surface); + Assert.Equal(CombatImpactOccurrenceBasis.ReconstructedTransition, tempo.OccurrenceBasis); + } + [Fact] public void Every_native_action_has_an_explicit_display_or_ignore_classification() {