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
Expand Up @@ -68,7 +68,9 @@ internal static IReadOnlyDictionary<string, CombatImpactEntity> Read()
card.Owner?.CombatantId,
effectAttributes.Abilities,
effectAttributes.Auras,
effectAttributes.ReferenceValuedAuraEffectIds
effectAttributes.ReferenceValuedAuraEffectIds,
card.LeftSocketId,
card.HiddenTags == null ? null : card.HiddenTags.ToArray()
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,9 @@ internal sealed record CombatImpactEntity(
ECombatantId? CombatantId = null,
IReadOnlyDictionary<string, ECardAttributeType>? AbilityAttributeTypesByEffectId = null,
IReadOnlyDictionary<string, ECardAttributeType>? AuraAttributeTypesByEffectId = null,
IReadOnlyCollection<string>? ReferenceValuedAuraEffectIds = null
IReadOnlyCollection<string>? ReferenceValuedAuraEffectIds = null,
EContainerSocketId? SocketId = null,
IReadOnlyCollection<EHiddenTag>? HiddenTags = null
);

internal sealed record CombatImpactEvent(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CombatImpactEntity> entities
Expand Down Expand Up @@ -162,6 +169,7 @@ IReadOnlyDictionary<string, CombatImpactEntity> entities
authoritative[sourceId] = metrics;
}

AddFMinorTempoEvents(entities, useCounts, events);
RecoverDroppedAppliedEffectCriticals(events, authoritative);

var report = CombatImpactAggregator.Aggregate(
Expand All @@ -174,6 +182,81 @@ IReadOnlyDictionary<string, CombatImpactEntity> entities
return AttachTriggerSources(report, triggerOccurrences, entities);
}

private static void AddFMinorTempoEvents(
IReadOnlyDictionary<string, CombatImpactEntity> entities,
IReadOnlyDictionary<string, int> useCounts,
ICollection<CombatImpactEvent> 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++)
Comment on lines +214 to +228
{
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<EContainerSocketId> 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<PeriodicImpactKey, CombatImpactPeriodicImpact> impacts
Expand Down
86 changes: 86 additions & 0 deletions tests/PostCombatImpact.Tests/CombatImpactProjectorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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, int>
{
[ECardStats.UseCount] = 7,
};
simulation.CardStats["non-haste-on-note"] = new Dictionary<ECardStats, int>
{
[ECardStats.UseCount] = 20,
};
simulation.CardStats["haste-off-note"] = new Dictionary<ECardStats, int>
{
[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, int>
{
[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()
{
Expand Down