diff --git a/src/BazaarPlusPlus/Game/EventPreview/EncounterEventDetailResolver.cs b/src/BazaarPlusPlus/Game/EventPreview/EncounterEventDetailResolver.cs index 5565ff37e..9bb8b3aca 100644 --- a/src/BazaarPlusPlus/Game/EventPreview/EncounterEventDetailResolver.cs +++ b/src/BazaarPlusPlus/Game/EventPreview/EncounterEventDetailResolver.cs @@ -10,7 +10,8 @@ internal static class EncounterEventDetailResolver EncounterPreviewSnapshot snapshot, EHero? currentHero, EncounterInventory? inventory = null, - int? currentDay = null + int? currentDay = null, + LiveAbilityValueResolver? resolveLiveValue = null ) { if ( @@ -21,14 +22,17 @@ internal static class EncounterEventDetailResolver ) return null; - var resultText = EventPreviewLocalization.ResolveDescription(eventTemplate) ?? string.Empty; + var resultText = + EventPreviewLocalization.ResolveDescription(eventTemplate, resolveLiveValue) + ?? string.Empty; var rewardFilter = ResolveRewardFilter(eventTemplate, resultText); var outcomeGroups = ResolveOutcomeGroups( eventPlan, snapshot, currentHero, inventory, - currentDay + currentDay, + resolveLiveValue ); // A suppressed random-selection event (shop stock generation) must not fall @@ -36,7 +40,14 @@ internal static class EncounterEventDetailResolver var choiceDetails = outcomeGroups != null || eventPlan.IsRandomSelectionEvent ? Array.Empty() - : ResolveChoiceDetails(eventPlan, snapshot, currentHero, inventory, currentDay); + : ResolveChoiceDetails( + eventPlan, + snapshot, + currentHero, + inventory, + currentDay, + resolveLiveValue + ); return new EncounterOption( eventTemplate.TemplateId, EventPreviewLocalization.ResolveTitle(eventTemplate) ?? eventTemplate.InternalName, @@ -55,7 +66,8 @@ internal static class EncounterEventDetailResolver EncounterPreviewSnapshot snapshot, EHero? currentHero, EncounterInventory? inventory, - int? currentDay + int? currentDay, + LiveAbilityValueResolver? resolveLiveValue ) { if (!eventPlan.IsRandomSelectionEvent || eventPlan.SuppressRandomOutcome) @@ -113,7 +125,7 @@ group.DayCondition is { } dayCondition ); continue; } - AddChoiceDetail(details, template, isEligible: true); + AddChoiceDetail(details, template, isEligible: true, resolveLiveValue); } // Dynamic pools roll a summary line; the reward filter drives the @@ -402,13 +414,15 @@ private static IReadOnlyList ResolveChoiceDetails( EncounterPreviewSnapshot snapshot, EHero? currentHero, EncounterInventory? inventory, - int? currentDay + int? currentDay, + LiveAbilityValueResolver? resolveLiveValue ) { var candidates = new List<(EncounterPreviewTemplatePlan Step, bool MeetsPrerequisites)>(); var pools = new List(); var eventDescription = snapshot.TryGetTemplate(eventPlan.TemplateId, out var eventTemplate) - ? EventPreviewLocalization.ResolveDescription(eventTemplate) ?? string.Empty + ? EventPreviewLocalization.ResolveDescription(eventTemplate, resolveLiveValue) + ?? string.Empty : string.Empty; foreach (var group in eventPlan.ChoiceGroups) { @@ -422,7 +436,14 @@ group.DayCondition is { } dayCondition if (group.IsRandomPool) { if ( - ResolveChoicePool(group, snapshot, currentHero, inventory, eventDescription) is + ResolveChoicePool( + group, + snapshot, + currentHero, + inventory, + eventDescription, + resolveLiveValue + ) is { } pool ) pools.Add(pool); @@ -459,10 +480,20 @@ group.DayCondition is { } dayCondition switch (dispositions[i]) { case ChoicePresentation.Presented: - AddChoiceDetail(presented, candidates[i].Step, isEligible: true); + AddChoiceDetail( + presented, + candidates[i].Step, + isEligible: true, + resolveLiveValue + ); break; case ChoicePresentation.Dimmed: - AddChoiceDetail(dimmed, candidates[i].Step, isEligible: false); + AddChoiceDetail( + dimmed, + candidates[i].Step, + isEligible: false, + resolveLiveValue + ); break; } } @@ -517,7 +548,8 @@ int limit EncounterPreviewSnapshot snapshot, EHero? currentHero, EncounterInventory? inventory, - string eventDescription + string eventDescription, + LiveAbilityValueResolver? resolveLiveValue ) { var entries = new List(); @@ -551,7 +583,7 @@ string eventDescription ); continue; } - AddChoiceDetail(entries, template, isEligible: true); + AddChoiceDetail(entries, template, isEligible: true, resolveLiveValue); } DedupeDetails(entries); @@ -616,10 +648,12 @@ private static bool MeetsOwnershipPrerequisites( private static void AddChoiceDetail( List result, EncounterPreviewTemplatePlan template, - bool isEligible + bool isEligible, + LiveAbilityValueResolver? resolveLiveValue ) { - var resultText = EventPreviewLocalization.ResolveDescription(template) ?? string.Empty; + var resultText = + EventPreviewLocalization.ResolveDescription(template, resolveLiveValue) ?? string.Empty; result.Add( new EncounterChoiceDetail( template.TemplateId, diff --git a/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewGameRuntime.cs b/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewGameRuntime.cs index 5a7a23169..0a7f43aad 100644 --- a/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewGameRuntime.cs +++ b/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewGameRuntime.cs @@ -3,7 +3,9 @@ using BazaarGameShared.Domain.Cards; using BazaarGameShared.Domain.Core.Types; using BazaarGameShared.Domain.Game; +using BazaarGameShared.Domain.Values; using BazaarPlusPlus.Game.Tooltips; +using BazaarPlusPlus.GameInterop.Cards; using BazaarPlusPlus.GameInterop.DayTiers; using BazaarPlusPlus.GameInterop.StaticCards; using BazaarPlusPlus.Infrastructure; @@ -19,6 +21,14 @@ internal interface IEncounterPreviewGameRuntime Task?> LoadCardMapAsync(object source); Dictionary? SnapshotLevelUps(object source); TCardBase? GetCardTemplate(object source, Guid templateId); + bool TryEvaluateAbilityValue( + object source, + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ); EHero? ReadCurrentHero(); EncounterInventory? ReadInventory(); GameDataDayTierResolution ResolveDayTiers(object source); @@ -51,6 +61,43 @@ IGameDataDayTierResolver dayTierResolver public TCardBase? GetCardTemplate(object source, Guid templateId) => BppStaticDataAccess.GetCardTemplate(source, templateId); + public bool TryEvaluateAbilityValue( + object source, + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) + { + valueText = string.Empty; + unit = null; + var template = GetCardTemplate(source, templateId); + var run = Data.Run; + if (template == null || run == null) + return false; + + try + { + var context = new ValueContext(run); + CardAbilityValue value; + var resolved = isAura + ? CardAbilityValueReader.TryEvaluateAura(template, effectId, context, out value) + : CardAbilityValueReader.TryEvaluate(template, effectId, context, out value); + if (!resolved) + return false; + valueText = value.ValueText; + unit = value.Unit; + return true; + } + catch (Exception) + { + // Unsupported live targets (for example, Self without a targeting card) + // degrade only this placeholder; the rest of the preview remains useful. + return false; + } + } + public EHero? ReadCurrentHero() { var runHero = Data.Run?.Player?.Hero; diff --git a/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewModule.cs b/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewModule.cs index 3900396fe..4049047b0 100644 --- a/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewModule.cs +++ b/src/BazaarPlusPlus/Game/EventPreview/EncounterPreviewModule.cs @@ -285,7 +285,8 @@ public EventPreviewResult ResolveEvent(EventPreviewQuery query) snapshot, _runtime.ReadCurrentHero(), _runtime.ReadInventory(), - currentDay + currentDay, + ResolveLiveValue ); if (option == null || (!option.HasChoiceDetails && !option.HasOutcomeGroups)) return new EventPreviewResult(EventPreviewAvailability.Unsupported, null); @@ -293,6 +294,22 @@ public EventPreviewResult ResolveEvent(EventPreviewQuery query) return Presentation( EncounterPreviewTextFormatter.Build(option, _runtime.ColorKeywords, dayTiers.Table) ); + + bool ResolveLiveValue( + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) => + _runtime.TryEvaluateAbilityValue( + source, + templateId, + effectId, + isAura, + out valueText, + out unit + ); } catch (Exception) { @@ -334,7 +351,7 @@ public EventPreviewResult ResolveLevelUp(LevelUpPreviewQuery query) { try { - if (!TryGetSnapshot(out _, out var snapshot, out var unavailable)) + if (!TryGetSnapshot(out var source, out var snapshot, out var unavailable)) return new EventPreviewResult(unavailable, null); if (!snapshot.TryGetLevelUp(query.CurrentLevel, out var levelUpPlan)) return new EventPreviewResult(EventPreviewAvailability.Missing, null); @@ -344,11 +361,28 @@ public EventPreviewResult ResolveLevelUp(LevelUpPreviewQuery query) id => snapshot.TryGetTemplate(id, out var template) ? template : null, _runtime.ReadCurrentHero(), _runtime.ColorKeywords, - query.CurrentLevel + query.CurrentLevel, + ResolveLiveValue ); return string.IsNullOrWhiteSpace(content) ? new EventPreviewResult(EventPreviewAvailability.Unsupported, null) : new EventPreviewResult(EventPreviewAvailability.Available, content); + + bool ResolveLiveValue( + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) => + _runtime.TryEvaluateAbilityValue( + source, + templateId, + effectId, + isAura, + out valueText, + out unit + ); } catch (Exception) { diff --git a/src/BazaarPlusPlus/Game/EventPreview/EventPreviewLocalization.cs b/src/BazaarPlusPlus/Game/EventPreview/EventPreviewLocalization.cs index c63e96654..1255d1913 100644 --- a/src/BazaarPlusPlus/Game/EventPreview/EventPreviewLocalization.cs +++ b/src/BazaarPlusPlus/Game/EventPreview/EventPreviewLocalization.cs @@ -6,6 +6,14 @@ namespace BazaarPlusPlus.Game.EventPreview; +internal delegate bool LiveAbilityValueResolver( + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit +); + internal static class EventPreviewLocalization { internal static Func? AttributeUnitLocalizer { get; set; } @@ -13,11 +21,18 @@ internal static class EventPreviewLocalization internal static string? ResolveTitle(EncounterPreviewTemplatePlan template) => template == null ? null : PickText(template.Title); - internal static string? ResolveDescription(EncounterPreviewTemplatePlan template) + internal static string? ResolveDescription( + EncounterPreviewTemplatePlan template, + LiveAbilityValueResolver? resolveLiveValue = null + ) { if (template == null) return null; - return FormatAbilityPlaceholders(template, PickText(template.Description)); + return FormatAbilityPlaceholders( + template, + PickText(template.Description), + resolveLiveValue + ); } internal static IReadOnlyDictionary CaptureAbilityValues( @@ -90,7 +105,8 @@ void AddAuraValue(string placeholder) private static string? FormatAbilityPlaceholders( EncounterPreviewTemplatePlan template, - string? text + string? text, + LiveAbilityValueResolver? resolveLiveValue ) => FormatAbilityPlaceholders( text, @@ -103,6 +119,21 @@ void AddAuraValue(string placeholder) return true; } + const string AuraPrefix = "aura."; + var isAura = abilityId.StartsWith(AuraPrefix, StringComparison.Ordinal); + var effectId = isAura ? abilityId[AuraPrefix.Length..] : abilityId; + if ( + resolveLiveValue != null + && resolveLiveValue( + template.TemplateId, + effectId, + isAura, + out valueText, + out unit + ) + ) + return true; + valueText = string.Empty; unit = null; return false; diff --git a/src/BazaarPlusPlus/Game/EventPreview/LevelUpPreviewTextFormatter.cs b/src/BazaarPlusPlus/Game/EventPreview/LevelUpPreviewTextFormatter.cs index a3895b54e..f21e8983e 100644 --- a/src/BazaarPlusPlus/Game/EventPreview/LevelUpPreviewTextFormatter.cs +++ b/src/BazaarPlusPlus/Game/EventPreview/LevelUpPreviewTextFormatter.cs @@ -24,7 +24,8 @@ public static string Build( Func resolveTemplate, EHero? currentHero, Func? colorizeResult = null, - int? currentLevel = null + int? currentLevel = null, + LiveAbilityValueResolver? resolveLiveValue = null ) { if (levelUp == null) @@ -71,7 +72,14 @@ public static string Build( uniformPool.AddRange(poolIds); continue; } - CollectGroup(candidates, group, resolveTemplate, currentHero, Colorize); + CollectGroup( + candidates, + group, + resolveTemplate, + currentHero, + Colorize, + resolveLiveValue + ); } if (uniformPool.Count > 0) CollectCandidates( @@ -80,7 +88,8 @@ public static string Build( limit: 1, resolveTemplate, currentHero, - Colorize + Colorize, + resolveLiveValue ); } @@ -132,7 +141,8 @@ private static void CollectGroup( LevelUpPreviewGroup group, Func resolveTemplate, EHero? currentHero, - Func colorize + Func colorize, + LiveAbilityValueResolver? resolveLiveValue ) { if (!PassesPrerequisites(group, currentHero)) @@ -143,7 +153,15 @@ Func colorize if (ids.Count == 0) return; - CollectCandidates(candidates, ids, group.Limit, resolveTemplate, currentHero, colorize); + CollectCandidates( + candidates, + ids, + group.Limit, + resolveTemplate, + currentHero, + colorize, + resolveLiveValue + ); } private static void CollectCandidates( @@ -152,7 +170,8 @@ private static void CollectCandidates( int limit, Func resolveTemplate, EHero? currentHero, - Func colorize + Func colorize, + LiveAbilityValueResolver? resolveLiveValue ) { // Spawn filtering also honours each reward card's own Heroes field (the group's @@ -179,7 +198,10 @@ Func colorize var template = eligible[0]; var title = EventPreviewLocalization.ResolveTitle(template) ?? template.InternalName; - var description = EventPreviewLocalization.ResolveDescription(template); + var description = EventPreviewLocalization.ResolveDescription( + template, + resolveLiveValue + ); if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(description)) return; @@ -225,7 +247,7 @@ Func colorize var entryTitle = EventPreviewLocalization.ResolveTitle(template) ?? template.InternalName; var entryDescription = EventPreviewLocalization - .ResolveDescription(template) + .ResolveDescription(template, resolveLiveValue) ?.Replace("\r", string.Empty) .Replace('\n', ' '); entries.Add( diff --git a/src/BazaarPlusPlus/GameInterop/Cards/CardAbilityValueReader.cs b/src/BazaarPlusPlus/GameInterop/Cards/CardAbilityValueReader.cs index 799002deb..f873ce286 100644 --- a/src/BazaarPlusPlus/GameInterop/Cards/CardAbilityValueReader.cs +++ b/src/BazaarPlusPlus/GameInterop/Cards/CardAbilityValueReader.cs @@ -48,6 +48,60 @@ internal static bool TryReadAura( out CardAbilityValue result ) => TryRead(template, AurasProperty, auraId, out result); + internal static bool TryEvaluate( + TCardBase template, + string abilityId, + ValueContext context, + out CardAbilityValue result + ) => TryEvaluate(template, AbilitiesProperty, abilityId, context, out result); + + internal static bool TryEvaluateAura( + TCardBase template, + string auraId, + ValueContext context, + out CardAbilityValue result + ) => TryEvaluate(template, AurasProperty, auraId, context, out result); + + // Static card data cannot resolve values backed by the current run (player + // attributes, owned-card counts, aggregate card attributes). The native game + // evaluates those ITValue graphs against a ValueContext; use that same graph at + // presentation time instead of reimplementing each reference-value subtype. + private static bool TryEvaluate( + TCardBase template, + string effectsProperty, + string effectId, + ValueContext context, + out CardAbilityValue result + ) + { + result = default; + if (effectId.IndexOf('.') >= 0) + return false; + + var effects = template.GetType().GetProperty(effectsProperty)?.GetValue(template); + if (effects is not IEnumerable enumerable) + return false; + + foreach (var entry in enumerable) + { + if (!TryReadEntry(entry, out var key, out var effect)) + continue; + if (!string.Equals(key?.ToString(), effectId, StringComparison.Ordinal)) + continue; + + var action = effect?.GetType().GetProperty("Action")?.GetValue(effect); + if (action?.GetType().GetProperty("Value")?.GetValue(action) is not ITValue value) + return false; + if (!TryFormatScalar(value.GetValue(context), out var valueText)) + return false; + + result = new CardAbilityValue(valueText, ResolveAttributeUnit(action)); + return true; + } + + return false; + } + private static bool TryRead( TCardBase template, string effectsProperty, diff --git a/tests/CollectionEncounterTooltip.Tests/CardAbilityValueReaderTests.cs b/tests/CollectionEncounterTooltip.Tests/CardAbilityValueReaderTests.cs new file mode 100644 index 000000000..f3e2e84fc --- /dev/null +++ b/tests/CollectionEncounterTooltip.Tests/CardAbilityValueReaderTests.cs @@ -0,0 +1,161 @@ +using BazaarGameClient.Domain.Cards; +using BazaarGameClient.Domain.Models; +using BazaarGameClient.Domain.Models.Cards; +using BazaarGameShared.Domain.Cards.Encounter.Step; +using BazaarGameShared.Domain.Core.Types; +using BazaarGameShared.Domain.Effect; +using BazaarGameShared.Domain.Effect.Actions; +using BazaarGameShared.Domain.Prerequisites.Conditionals; +using BazaarGameShared.Domain.Targeting; +using BazaarGameShared.Domain.Values; +using BazaarGameShared.Domain.Values.ReferenceValues; +using BazaarPlusPlus.GameInterop.Cards; +using Xunit; + +namespace EncounterTooltip.Tests; + +public sealed class CardAbilityValueReaderTests +{ + [Fact] + public void Live_evaluation_uses_the_current_player_attribute_and_native_modifier() + { + var run = RunWithPlayerAttribute(EPlayerAttributeType.Custom_8, 3); + var template = Template( + new TReferenceValuePlayerAttribute + { + AttributeType = EPlayerAttributeType.Custom_8, + Target = new TTargetPlayerAbsolute + { + TargetMode = ETargetPlayerAbsoluteTargetMode.Player, + }, + Modifier = MultiplyBy(2f), + }, + EPlayerAttributeType.Prestige + ); + + Assert.True( + CardAbilityValueReader.TryEvaluate(template, "0", new ValueContext(run), out var value) + ); + Assert.Equal("6", value.ValueText); + } + + [Fact] + public void Live_evaluation_counts_matching_cards_in_hand_and_stash() + { + var run = RunWithPlayerAttribute(EPlayerAttributeType.Gold, 0); + AddCard(run.Player, toStash: false, Guid.NewGuid(), ECardTag.Friend); + AddCard(run.Player, toStash: true, Guid.NewGuid(), ECardTag.Relic); + AddCard(run.Player, toStash: true, Guid.NewGuid(), ECardTag.Tool); + var template = Template( + new TReferenceValueCardCount + { + Target = new TTargetCardSection + { + TargetSection = ETargetCardSectionTargetSection.AbsolutePlayerHandAndStash, + Conditions = new TCardConditionalTag + { + Tags = new HashSet { ECardTag.Friend, ECardTag.Relic }, + Operator = EListComparisonOperator.Any, + }, + }, + Modifier = MultiplyBy(5f), + }, + EPlayerAttributeType.Gold + ); + + Assert.True( + CardAbilityValueReader.TryEvaluate(template, "0", new ValueContext(run), out var value) + ); + Assert.Equal("10", value.ValueText); + } + + [Fact] + public void Live_evaluation_supports_nested_card_attribute_aggregates() + { + var locketId = Guid.Parse("d641c57b-ab42-4b2f-b4f4-addd18eb7100"); + var run = RunWithPlayerAttribute(EPlayerAttributeType.HealthMax, 1000); + var locket = AddCard(run.Player, toStash: false, locketId, ECardTag.Relic); + locket.Attributes[ECardAttributeType.Custom_7] = 4; + var percentPerUse = new TReferenceValueCardAttributeAggregate + { + AttributeType = ECardAttributeType.Custom_7, + Target = new TTargetCardSection + { + TargetSection = ETargetCardSectionTargetSection.AbsolutePlayerHandAndStash, + Conditions = new TCardConditionalId { Id = locketId }, + }, + Modifier = MultiplyBy(0.01f, shouldRound: false), + }; + var template = Template( + new TReferenceValuePlayerAttributeUnscaled + { + AttributeType = EPlayerAttributeType.HealthMax, + Target = new TTargetPlayerAbsolute + { + TargetMode = ETargetPlayerAbsoluteTargetMode.Player, + }, + Modifier = new TValueModifier + { + ModifyMode = EValueModifierMode.Multiply, + Value = percentPerUse, + ShouldRound = true, + }, + }, + EPlayerAttributeType.HealthMax + ); + + Assert.True( + CardAbilityValueReader.TryEvaluate(template, "0", new ValueContext(run), out var value) + ); + Assert.Equal("40", value.ValueText); + } + + private static TCardEncounterStep Template(ITValue value, EPlayerAttributeType attributeType) => + new() + { + Abilities = new Dictionary + { + ["0"] = new TCardAbility + { + Action = new TActionPlayerModifyAttribute + { + AttributeType = attributeType, + Value = value, + }, + }, + }, + }; + + private static Run RunWithPlayerAttribute(EPlayerAttributeType attributeType, int value) => + new() + { + Player = new Player + { + Attributes = new Dictionary { [attributeType] = value }, + }, + }; + + private static ItemCard AddCard(Player player, bool toStash, Guid templateId, ECardTag tag) + { + var card = new ItemCard + { + TemplateId = templateId, + Size = ECardSize.Small, + Tags = new HashSet { tag }, + Owner = player, + }; + var container = Assert.IsType(toStash ? player.Stash : player.Hand); + var socket = Array.FindIndex(container.Container.Sockets, entry => entry == null); + Assert.True(socket >= 0); + container.Container.Sockets[socket] = card; + return card; + } + + private static TValueModifier MultiplyBy(float value, bool shouldRound = true) => + new() + { + ModifyMode = EValueModifierMode.Multiply, + Value = new TFixedValue { Value = value }, + ShouldRound = shouldRound, + }; +} diff --git a/tests/CollectionEncounterTooltip.Tests/CollectionEncounterTooltip.Tests.csproj b/tests/CollectionEncounterTooltip.Tests/CollectionEncounterTooltip.Tests.csproj index 35e110bfb..f08e58502 100644 --- a/tests/CollectionEncounterTooltip.Tests/CollectionEncounterTooltip.Tests.csproj +++ b/tests/CollectionEncounterTooltip.Tests/CollectionEncounterTooltip.Tests.csproj @@ -29,6 +29,9 @@ $(ManagedPath)/BazaarGameShared.dll + + $(ManagedPath)/BazaarGameClient.dll + $(ManagedPath)/UnityEngine.CoreModule.dll diff --git a/tests/CollectionEncounterTooltip.Tests/CollectionLevelUpTooltipTextTests.cs b/tests/CollectionEncounterTooltip.Tests/CollectionLevelUpTooltipTextTests.cs index ec9066eec..7c445661a 100644 --- a/tests/CollectionEncounterTooltip.Tests/CollectionLevelUpTooltipTextTests.cs +++ b/tests/CollectionEncounterTooltip.Tests/CollectionLevelUpTooltipTextTests.cs @@ -101,6 +101,52 @@ public void Build_returns_empty_for_null_level_up() ); } + [Fact] + public void Build_uses_live_values_for_dynamic_reward_totals() + { + var rewardId = Guid.Parse("30000000-0000-0000-0000-0000000000d1"); + var levelUp = new TLevelUp + { + Level = 3, + Rewards = new TSpawnContextQuery { Groups = { Group(new[] { rewardId }, limit: 1) } }, + }; + var reward = new EncounterPreviewTemplatePlan( + rewardId, + EncounterPreviewTemplateKind.EncounterStep, + Array.Empty(), + "Prosperous Estates", + new EncounterPreviewLocalizedText(null, "Prosperous Estates"), + new EncounterPreviewLocalizedText( + null, + "Gain {ability.0.mod} Gold for each Property you have (including Stash) [{ability.0}]" + ), + new Dictionary { ["0.mod"] = new("5") }, + rewardFilter: null + ); + + var text = LevelUpPreviewTextFormatter.Build( + Plan(levelUp), + id => id == rewardId ? reward : null, + currentHero: null, + resolveLiveValue: ResolveLiveValue + ); + + Assert.Contains("Gain 5 Gold for each Property you have (including Stash) [15]", text); + + bool ResolveLiveValue( + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) + { + valueText = templateId == rewardId && effectId == "0" && !isAura ? "15" : string.Empty; + unit = null; + return valueText.Length > 0; + } + } + [Fact] public void Build_uses_chinese_colon_without_a_following_space() { diff --git a/tests/CollectionEncounterTooltip.Tests/EncounterPreviewModuleTests.cs b/tests/CollectionEncounterTooltip.Tests/EncounterPreviewModuleTests.cs index 70e3910c1..f2c7816dd 100644 --- a/tests/CollectionEncounterTooltip.Tests/EncounterPreviewModuleTests.cs +++ b/tests/CollectionEncounterTooltip.Tests/EncounterPreviewModuleTests.cs @@ -409,6 +409,8 @@ public void Compiler_and_module_format_fixed_modifier_live_and_unit_placeholders { CurrentHero = EHero.Jules, Inventory = new EncounterInventory(Array.Empty()), + LiveValueResolver = (_, effectId, isAura) => + effectId == "0" && !isAura ? (true, "6", null) : (false, string.Empty, null), }; EventPreviewLocalization.AttributeUnitLocalizer = unit => unit == "Gold" ? "Coins" : null; using var module = Module( @@ -420,7 +422,11 @@ public void Compiler_and_module_format_fixed_modifier_live_and_unit_placeholders Assert.Equal(EventPreviewAvailability.Available, result.Availability); Assert.Contains("Gain 10 Coins.", result.Content); - Assert.Contains("Gain 1 Gold for each Food", result.Content); + Assert.Contains("Gain 1 Gold for each Food [6]", result.Content); + Assert.Contains( + "Gain 2 Prestige for each Player fight you have lost this run [6]", + result.Content + ); Assert.DoesNotContain("{ability.", result.Content); Assert.Contains("Gain 5 Income.", result.Content); } @@ -555,6 +561,7 @@ private static EncounterPreviewSnapshot CompiledLocalizationSnapshot() var fixedId = Guid.Parse("91000000-0000-0000-0000-000000000011"); var modifierId = Guid.Parse("91000000-0000-0000-0000-000000000012"); var fallbackId = Guid.Parse("91000000-0000-0000-0000-000000000013"); + var playerAttributeId = Guid.Parse("91000000-0000-0000-0000-000000000014"); var eventCard = new TCardEncounterEvent { Id = EventId, @@ -565,7 +572,7 @@ private static EncounterPreviewSnapshot CompiledLocalizationSnapshot() { SpawnContext = new TSpawnContextQuery { - Limit = new TFixedValue { Value = 3 }, + Limit = new TFixedValue { Value = 4 }, Groups = new List { new() @@ -574,7 +581,13 @@ private static EncounterPreviewSnapshot CompiledLocalizationSnapshot() { new TSpawnFilterIdList { - Ids = new List { fixedId, modifierId, fallbackId }, + Ids = new List + { + fixedId, + modifierId, + fallbackId, + playerAttributeId, + }, }, }, }, @@ -614,12 +627,32 @@ private static EncounterPreviewSnapshot CompiledLocalizationSnapshot() Value = new TFixedValue { Value = 5f }, } ); + var playerAttributeStep = Step( + playerAttributeId, + "Player Attribute", + "Gain {ability.0.mod} Prestige for each Player fight you have lost this run [{ability.0}]", + new TActionPlayerModifyAttribute + { + AttributeType = EPlayerAttributeType.Prestige, + Value = new TReferenceValuePlayerAttribute + { + AttributeType = EPlayerAttributeType.Custom_8, + Modifier = new TValueModifier + { + ModifyMode = EValueModifierMode.Multiply, + Value = new TFixedValue { Value = 2f }, + ShouldRound = true, + }, + }, + } + ); var map = new Dictionary { [EventId] = eventCard, [fixedId] = fixedStep, [modifierId] = modifierStep, [fallbackId] = fallbackStep, + [playerAttributeId] = playerAttributeStep, }; var compiler = new EncounterPreviewPlanCompiler(source => { @@ -630,11 +663,11 @@ private static EncounterPreviewSnapshot CompiledLocalizationSnapshot() { "SelectionContext": { "SpawnContext": { - "Limit": { "Value": 3 }, + "Limit": { "Value": 4 }, "Groups": [ { "Filters": [ - { "Ids": ["{{fixedId:D}}", "{{modifierId:D}}", "{{fallbackId:D}}"] } + { "Ids": ["{{fixedId:D}}", "{{modifierId:D}}", "{{fallbackId:D}}", "{{playerAttributeId:D}}"] } ] } ] @@ -702,6 +735,12 @@ private sealed class FakeRuntime(object source) : IEncounterPreviewGameRuntime public object Source { get; set; } = source; public EHero? CurrentHero { get; set; } + public Func< + Guid, + string, + bool, + (bool Success, string ValueText, string? Unit) + >? LiveValueResolver { get; set; } public EncounterInventory? Inventory { get; set; } public Action? OnResolveDayTiers { get; set; } public bool ThrowOnDayTierRead { get; set; } @@ -735,6 +774,21 @@ private sealed class FakeRuntime(object source) : IEncounterPreviewGameRuntime public TCardBase? GetCardTemplate(object source, Guid templateId) => null; + public bool TryEvaluateAbilityValue( + object source, + Guid templateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) + { + var result = LiveValueResolver?.Invoke(templateId, effectId, isAura) ?? default; + valueText = result.ValueText ?? string.Empty; + unit = result.Unit; + return result.Success; + } + public EHero? ReadCurrentHero() => CurrentHero; public EncounterInventory? ReadInventory() => Inventory; diff --git a/tests/CollectionEncounterTooltip.Tests/EventPreviewLocalizationTests.cs b/tests/CollectionEncounterTooltip.Tests/EventPreviewLocalizationTests.cs index b58fd17c7..e0e170145 100644 --- a/tests/CollectionEncounterTooltip.Tests/EventPreviewLocalizationTests.cs +++ b/tests/CollectionEncounterTooltip.Tests/EventPreviewLocalizationTests.cs @@ -12,6 +12,51 @@ namespace EncounterTooltip.Tests; public sealed class EventPreviewLocalizationTests { + [Fact] + public void Missing_static_value_uses_the_live_game_value() + { + var templateId = Guid.Parse("a7c9181b-6fa5-48b8-a870-90a9c054dc1d"); + var plan = new EncounterPreviewTemplatePlan( + templateId, + EncounterPreviewTemplateKind.EncounterStep, + Array.Empty(), + "Heroic Encounters (Dabora)", + new EncounterPreviewLocalizedText(null, "Heroic Encounters"), + new EncounterPreviewLocalizedText( + null, + "Gain {ability.0.mod} Prestige for each Player fight you have lost this run [{ability.0}]" + ), + new Dictionary + { + ["0.mod"] = new EncounterPreviewAbilityValue("2"), + }, + rewardFilter: null + ); + + var description = EventPreviewLocalization.ResolveDescription(plan, ResolveLiveValue); + + Assert.Equal( + "Gain 2 Prestige for each Player fight you have lost this run [6]", + description + ); + + bool ResolveLiveValue( + Guid requestedTemplateId, + string effectId, + bool isAura, + out string valueText, + out string? unit + ) + { + Assert.Equal(templateId, requestedTemplateId); + Assert.Equal("0", effectId); + Assert.False(isAura); + valueText = "6"; + unit = null; + return true; + } + } + [Theory] [InlineData(EPlayerAttributeType.Experience, 3, "XP")] [InlineData(EPlayerAttributeType.Gold, 30, "Gold")]