diff --git a/Assets/Editor/ModifierAssetCreator.cs b/Assets/Editor/ModifierAssetCreator.cs deleted file mode 100644 index f5d93db..0000000 --- a/Assets/Editor/ModifierAssetCreator.cs +++ /dev/null @@ -1,130 +0,0 @@ -#if UNITY_EDITOR -using UnityEditor; -using UnityEngine; -using YachtDice.Modifiers; -using YachtDice.Scoring; -using YachtDice.Shop; - -namespace YachtDice.Editor -{ - -public static class ModifierAssetCreator -{ - private const string BasePath = "Assets/ScriptableObjects/Modifiers"; - private const string CatalogPath = "Assets/ScriptableObjects"; - - [MenuItem("YachtDice/Create Example Modifiers + Catalog")] - public static void CreateAll() - { - EnsureFolder(BasePath); - EnsureFolder(CatalogPath); - - var m1 = CreateModifier("BonusPerOne", "Bonus Per One", - "+10 за каждый кубик со значением 1", - ModifierRarity.Common, 100, 50, - ModifierScope.SelectedCategory, ModifierEffectType.AddPerDieValue, - 10f, 1, YachtCategory.Ones, false, - ModifierDurability.Permanent, 0); - - var m2 = CreateModifier("MultiplierPerSix", "Multiplier Per Six", - "x6 за каждый кубик со значением 6", - ModifierRarity.Rare, 200, 100, - ModifierScope.SelectedCategory, ModifierEffectType.MultiplyPerDieValue, - 6f, 6, YachtCategory.Ones, false, - ModifierDurability.Permanent, 0); - - var m3 = CreateModifier("FullHouseFlat", "Full House Flat Bonus", - "+15 при закрытии Full House", - ModifierRarity.Uncommon, 150, 75, - ModifierScope.SelectedCategory, ModifierEffectType.AddFlatToFinalScore, - 15f, 0, YachtCategory.FullHouse, true, - ModifierDurability.Permanent, 0); - - var m4 = CreateModifier("YachtDoubler", "Yacht Doubler", - "x2 при закрытии Yacht (3 использования)", - ModifierRarity.Epic, 300, 150, - ModifierScope.SelectedCategory, ModifierEffectType.MultiplyFinalScore, - 2f, 0, YachtCategory.Yacht, true, - ModifierDurability.LimitedUses, 3); - - var m5 = CreateModifier("FiveBonusGlobal", "Five Bonus Global", - "+5 за каждую пятёрку при закрытии любой категории", - ModifierRarity.Uncommon, 250, 125, - ModifierScope.AnyCategoryClosed, ModifierEffectType.AddPerDieValue, - 5f, 5, YachtCategory.Ones, false, - ModifierDurability.Permanent, 0); - - var m6 = CreateModifier("CloseMultiplier", "Close Multiplier", - "x1.1 при закрытии любой категории (5 использований)", - ModifierRarity.Rare, 350, 175, - ModifierScope.AnyCategoryClosed, ModifierEffectType.MultiplyFinalScore, - 1.1f, 0, YachtCategory.Ones, false, - ModifierDurability.LimitedUses, 5); - - // Create ShopCatalog - var catalog = ScriptableObject.CreateInstance(); - var catalogSO = new SerializedObject(catalog); - var listProp = catalogSO.FindProperty("availableModifiers"); - listProp.arraySize = 6; - listProp.GetArrayElementAtIndex(0).objectReferenceValue = m1; - listProp.GetArrayElementAtIndex(1).objectReferenceValue = m2; - listProp.GetArrayElementAtIndex(2).objectReferenceValue = m3; - listProp.GetArrayElementAtIndex(3).objectReferenceValue = m4; - listProp.GetArrayElementAtIndex(4).objectReferenceValue = m5; - listProp.GetArrayElementAtIndex(5).objectReferenceValue = m6; - catalogSO.ApplyModifiedProperties(); - AssetDatabase.CreateAsset(catalog, $"{CatalogPath}/ShopCatalog.asset"); - - AssetDatabase.SaveAssets(); - AssetDatabase.Refresh(); - Debug.Log("Created 6 example modifiers and ShopCatalog."); - } - - private static ModifierData CreateModifier( - string id, string displayName, string description, - ModifierRarity rarity, int shopPrice, int sellPrice, - ModifierScope scope, ModifierEffectType effectType, - float effectValue, int dieValue, YachtCategory targetCategory, bool hasCategoryFilter, - ModifierDurability durability, int maxUses) - { - var data = ScriptableObject.CreateInstance(); - - var so = new SerializedObject(data); - so.FindProperty("id").stringValue = id; - so.FindProperty("displayName").stringValue = displayName; - so.FindProperty("description").stringValue = description; - so.FindProperty("rarity").enumValueIndex = (int)rarity; - so.FindProperty("shopPrice").intValue = shopPrice; - so.FindProperty("sellPrice").intValue = sellPrice; - so.FindProperty("scope").enumValueIndex = (int)scope; - so.FindProperty("effectType").enumValueIndex = (int)effectType; - so.FindProperty("effectValue").floatValue = effectValue; - so.FindProperty("durability").enumValueIndex = (int)durability; - so.FindProperty("maxUses").intValue = maxUses; - - var targetProp = so.FindProperty("target"); - targetProp.FindPropertyRelative("DieValue").intValue = dieValue; - targetProp.FindPropertyRelative("TargetCategory").enumValueIndex = (int)targetCategory; - targetProp.FindPropertyRelative("HasCategoryFilter").boolValue = hasCategoryFilter; - - so.ApplyModifiedProperties(); - - AssetDatabase.CreateAsset(data, $"{BasePath}/{id}.asset"); - return data; - } - - private static void EnsureFolder(string path) - { - string[] parts = path.Split('/'); - string current = parts[0]; - for (int i = 1; i < parts.Length; i++) - { - string next = current + "/" + parts[i]; - if (!AssetDatabase.IsValidFolder(next)) - AssetDatabase.CreateFolder(current, parts[i]); - current = next; - } - } -} -} -#endif diff --git a/Assets/Scripts/DI/GameLifetimeScope.cs b/Assets/Scripts/DI/GameLifetimeScope.cs new file mode 100644 index 0000000..dfa59eb --- /dev/null +++ b/Assets/Scripts/DI/GameLifetimeScope.cs @@ -0,0 +1,41 @@ +using UnityEngine; +using VContainer; +using VContainer.Unity; +using YachtDice.Economy; +using YachtDice.Events; +using YachtDice.Game; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Pipeline; +using YachtDice.Modifiers.Runtime; +using YachtDice.Scoring; + +namespace YachtDice.DI +{ + public class GameLifetimeScope : LifetimeScope + { + [SerializeField] private ModifierCatalogSO modifierCatalog; + + [Header("Scene References")] + [SerializeField] private ScoringSystem scoringSystem; + [SerializeField] private CurrencyBank currencyBank; + [SerializeField] private GameManager gameManager; + [SerializeField] private DiceManager diceManager; + + protected override void Configure(IContainerBuilder builder) + { + // SO catalog + builder.RegisterInstance(modifierCatalog); + + // Core modifier services + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + builder.Register(Lifetime.Singleton); + + // Scene MonoBehaviour components + builder.RegisterComponent(scoringSystem); + builder.RegisterComponent(currencyBank); + builder.RegisterComponent(gameManager); + builder.RegisterComponent(diceManager); + } + } +} diff --git a/Assets/Scripts/Events/GameEventBus.cs b/Assets/Scripts/Events/GameEventBus.cs new file mode 100644 index 0000000..ea18ccb --- /dev/null +++ b/Assets/Scripts/Events/GameEventBus.cs @@ -0,0 +1,21 @@ +using Cysharp.Threading.Tasks; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Pipeline; + +namespace YachtDice.Events +{ + public class GameEventBus + { + private readonly ModifierPipeline pipeline; + + public GameEventBus(ModifierPipeline pipeline) + { + this.pipeline = pipeline; + } + + public UniTask Fire(TriggerType trigger, ModifierContext context) + { + return pipeline.Execute(trigger, context); + } + } +} diff --git a/Assets/Scripts/Inventory/InventoryController.cs b/Assets/Scripts/Inventory/InventoryController.cs index 45e80e0..33dd263 100644 --- a/Assets/Scripts/Inventory/InventoryController.cs +++ b/Assets/Scripts/Inventory/InventoryController.cs @@ -1,6 +1,6 @@ using UnityEngine; using YachtDice.Economy; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Runtime; using YachtDice.Scoring; namespace YachtDice.Inventory @@ -23,7 +23,6 @@ namespace YachtDice.Inventory inventoryView.OnDeactivateClicked += HandleDeactivate; inventoryView.OnSellClicked += HandleSell; - model.OnActiveModifiersChanged += HandleActiveModifiersChanged; model.OnInventoryChanged += HandleInventoryChanged; if (scoringSystem != null) @@ -42,42 +41,33 @@ namespace YachtDice.Inventory } if (model != null) - { - model.OnActiveModifiersChanged -= HandleActiveModifiersChanged; model.OnInventoryChanged -= HandleInventoryChanged; - } if (scoringSystem != null) scoringSystem.OnCategoryConfirmed -= HandleCategoryConfirmed; } - private void HandleActivate(ModifierRuntime runtime) + private void HandleActivate(ModifierInstance instance) { - model.TryActivate(runtime); + model.TryActivate(instance); } - private void HandleDeactivate(ModifierRuntime runtime) + private void HandleDeactivate(ModifierInstance instance) { - model.Deactivate(runtime); + model.Deactivate(instance); } - private void HandleSell(ModifierRuntime runtime) + private void HandleSell(ModifierInstance instance) { - if (runtime.Data == null) return; + if (instance.Definition == null) return; - int sellPrice = runtime.Data.SellPrice; - model.RemoveModifier(runtime); + int sellPrice = instance.Definition.SellPrice; + model.RemoveModifier(instance); if (currencyBank != null) currencyBank.Add(sellPrice); } - private void HandleActiveModifiersChanged(System.Collections.Generic.List activeModifiers) - { - if (scoringSystem != null) - scoringSystem.SetActiveModifiers(activeModifiers); - } - private void HandleInventoryChanged() { RefreshView(); diff --git a/Assets/Scripts/Inventory/InventoryModel.cs b/Assets/Scripts/Inventory/InventoryModel.cs index 397de7d..afcfead 100644 --- a/Assets/Scripts/Inventory/InventoryModel.cs +++ b/Assets/Scripts/Inventory/InventoryModel.cs @@ -1,131 +1,48 @@ using System; using System.Collections.Generic; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Inventory { public class InventoryModel { - private readonly List ownedModifiers = new(); - private int maxActiveSlots; - - public IReadOnlyList OwnedModifiers => ownedModifiers; - public int MaxActiveSlots => maxActiveSlots; + private readonly ModifierRegistry registry; public event Action OnInventoryChanged; - public event Action> OnActiveModifiersChanged; + public event Action> OnActiveModifiersChanged; - public InventoryModel(int maxActiveSlots = 5) + public InventoryModel(ModifierRegistry registry) { - this.maxActiveSlots = maxActiveSlots; + this.registry = registry; + + registry.OnChanged += () => OnInventoryChanged?.Invoke(); + registry.OnActiveModifiersChanged += list => OnActiveModifiersChanged?.Invoke(list); } - public int ActiveCount + public IReadOnlyList OwnedModifiers => registry.All; + public int MaxActiveSlots => registry.MaxActiveSlots; + public int ActiveCount => registry.ActiveCount; + + public void SetMaxActiveSlots(int slots) => registry.SetMaxActiveSlots(slots); + + public void AddModifier(ModifierDefinitionSO definition) => registry.Add(definition); + + public void RemoveModifier(ModifierInstance instance) => registry.Remove(instance); + + public bool TryActivate(ModifierInstance instance) => registry.TryActivate(instance); + + public void Deactivate(ModifierInstance instance) => registry.Deactivate(instance); + + public void ConsumeUseOnActive() => registry.ConsumeChargesOnActive(); + + public List GetActiveModifierDefinitions() { - get - { - int count = 0; - for (int i = 0; i < ownedModifiers.Count; i++) - if (ownedModifiers[i].IsActive) count++; - return count; - } - } - - public void SetMaxActiveSlots(int slots) - { - maxActiveSlots = slots; - } - - public void AddModifier(ModifierData data) - { - var runtime = ModifierRuntime.Create(data); - ownedModifiers.Add(runtime); - OnInventoryChanged?.Invoke(); - } - - public void RemoveModifier(ModifierRuntime modifier) - { - if (!ownedModifiers.Contains(modifier)) return; - - if (modifier.IsActive) - { - modifier.IsActive = false; - OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); - } - - ownedModifiers.Remove(modifier); - OnInventoryChanged?.Invoke(); - } - - public bool TryActivate(ModifierRuntime modifier) - { - if (modifier.IsActive) return false; - if (!ownedModifiers.Contains(modifier)) return false; - if (ActiveCount >= maxActiveSlots) return false; - - modifier.IsActive = true; - OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); - OnInventoryChanged?.Invoke(); - return true; - } - - public void Deactivate(ModifierRuntime modifier) - { - if (!modifier.IsActive) return; - - modifier.IsActive = false; - OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); - OnInventoryChanged?.Invoke(); - } - - public void ConsumeUseOnActive() - { - bool changed = false; - - for (int i = ownedModifiers.Count - 1; i >= 0; i--) - { - var mod = ownedModifiers[i]; - if (!mod.IsActive) continue; - if (mod.Data == null) continue; - if (mod.Data.Durability != ModifierDurability.LimitedUses) continue; - - mod.ConsumeUse(); - - if (mod.IsExpired) - { - ownedModifiers.RemoveAt(i); - changed = true; - } - } - - if (changed) - { - OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); - OnInventoryChanged?.Invoke(); - } - } - - public List GetActiveModifierData() - { - var result = new List(); - for (int i = 0; i < ownedModifiers.Count; i++) - { - if (ownedModifiers[i].IsActive && ownedModifiers[i].Data != null) - result.Add(ownedModifiers[i].Data); - } + var result = new List(); + var active = registry.Active; + for (int i = 0; i < active.Count; i++) + result.Add(active[i].Definition); return result; } - - public void LoadState(List loaded) - { - ownedModifiers.Clear(); - if (loaded != null) - ownedModifiers.AddRange(loaded); - - OnActiveModifiersChanged?.Invoke(GetActiveModifierData()); - OnInventoryChanged?.Invoke(); - } - - public List GetAllForSave() => new(ownedModifiers); } } diff --git a/Assets/Scripts/Inventory/InventorySlotView.cs b/Assets/Scripts/Inventory/InventorySlotView.cs index 5b378b3..c94d499 100644 --- a/Assets/Scripts/Inventory/InventorySlotView.cs +++ b/Assets/Scripts/Inventory/InventorySlotView.cs @@ -2,7 +2,7 @@ using System; using TMPro; using UnityEngine; using UnityEngine.UI; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Inventory { @@ -22,39 +22,39 @@ namespace YachtDice.Inventory [SerializeField] private Color activeColor = new(0.7f, 1f, 0.7f); [SerializeField] private Color inactiveColor = Color.white; - private ModifierRuntime runtime; + private ModifierInstance instance; - public event Action OnActivateClicked; - public event Action OnDeactivateClicked; - public event Action OnSellClicked; + public event Action OnActivateClicked; + public event Action OnDeactivateClicked; + public event Action OnSellClicked; private void Awake() { if (activateButton != null) - activateButton.onClick.AddListener(() => OnActivateClicked?.Invoke(runtime)); + activateButton.onClick.AddListener(() => OnActivateClicked?.Invoke(instance)); if (deactivateButton != null) - deactivateButton.onClick.AddListener(() => OnDeactivateClicked?.Invoke(runtime)); + deactivateButton.onClick.AddListener(() => OnDeactivateClicked?.Invoke(instance)); if (sellButton != null) - sellButton.onClick.AddListener(() => OnSellClicked?.Invoke(runtime)); + sellButton.onClick.AddListener(() => OnSellClicked?.Invoke(instance)); } - public void Setup(ModifierRuntime modifierRuntime, bool canActivateMore) + public void Setup(ModifierInstance modifierInstance, bool canActivateMore) { - runtime = modifierRuntime; - var data = runtime.Data; + instance = modifierInstance; + var def = instance.Definition; - if (data == null) return; + if (def == null) return; - if (nameText != null) nameText.text = data.DisplayName; - if (descriptionText != null) descriptionText.text = data.Description; - if (iconImage != null && data.Icon != null) iconImage.sprite = data.Icon; + if (nameText != null) nameText.text = def.DisplayName; + if (descriptionText != null) descriptionText.text = def.Description; + if (iconImage != null && def.Icon != null) iconImage.sprite = def.Icon; if (usesText != null) { - if (data.Durability == ModifierDurability.LimitedUses) + if (def.HasLimitedUses) { usesText.gameObject.SetActive(true); - usesText.text = $"{runtime.RemainingUses}/{data.MaxUses}"; + usesText.text = $"{instance.RemainingUses}/{def.MaxUses}"; } else { @@ -62,9 +62,9 @@ namespace YachtDice.Inventory } } - if (sellPriceText != null) sellPriceText.text = data.SellPrice.ToString(); + if (sellPriceText != null) sellPriceText.text = def.SellPrice.ToString(); - bool isActive = runtime.IsActive; + bool isActive = instance.IsActive; if (activateButton != null) { diff --git a/Assets/Scripts/Inventory/InventoryView.cs b/Assets/Scripts/Inventory/InventoryView.cs index 2148cfe..2b5710b 100644 --- a/Assets/Scripts/Inventory/InventoryView.cs +++ b/Assets/Scripts/Inventory/InventoryView.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Inventory { @@ -16,9 +16,9 @@ namespace YachtDice.Inventory private readonly List spawnedSlots = new(); - public event Action OnActivateClicked; - public event Action OnDeactivateClicked; - public event Action OnSellClicked; + public event Action OnActivateClicked; + public event Action OnDeactivateClicked; + public event Action OnSellClicked; private void Awake() { @@ -36,7 +36,7 @@ namespace YachtDice.Inventory public void Hide() => gameObject.SetActive(false); public bool IsVisible => gameObject.activeSelf; - public void Refresh(IReadOnlyList owned, int maxSlots) + public void Refresh(IReadOnlyList owned, int maxSlots) { ClearSlots(); @@ -44,11 +44,11 @@ namespace YachtDice.Inventory for (int i = 0; i < owned.Count; i++) { - var runtime = owned[i]; - if (runtime.IsActive) activeCount++; + var inst = owned[i]; + if (inst.IsActive) activeCount++; var slot = Instantiate(slotPrefab, slotContainer); - slot.Setup(runtime, activeCount <= maxSlots); + slot.Setup(inst, activeCount <= maxSlots); slot.OnActivateClicked += HandleActivate; slot.OnDeactivateClicked += HandleDeactivate; slot.OnSellClicked += HandleSell; @@ -71,8 +71,8 @@ namespace YachtDice.Inventory spawnedSlots.Clear(); } - private void HandleActivate(ModifierRuntime runtime) => OnActivateClicked?.Invoke(runtime); - private void HandleDeactivate(ModifierRuntime runtime) => OnDeactivateClicked?.Invoke(runtime); - private void HandleSell(ModifierRuntime runtime) => OnSellClicked?.Invoke(runtime); + private void HandleActivate(ModifierInstance inst) => OnActivateClicked?.Invoke(inst); + private void HandleDeactivate(ModifierInstance inst) => OnDeactivateClicked?.Invoke(inst); + private void HandleSell(ModifierInstance inst) => OnSellClicked?.Invoke(inst); } } diff --git a/Assets/Scripts/Modifiers/Conditions/CategoryCondition.cs b/Assets/Scripts/Modifiers/Conditions/CategoryCondition.cs new file mode 100644 index 0000000..98612da --- /dev/null +++ b/Assets/Scripts/Modifiers/Conditions/CategoryCondition.cs @@ -0,0 +1,28 @@ +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; +using YachtDice.Scoring; + +namespace YachtDice.Modifiers.Conditions +{ + [CreateAssetMenu(fileName = "CategoryCondition", menuName = "YachtDice/Modifiers/Conditions/Category")] + public class CategoryCondition : ConditionSO + { + [SerializeField] private YachtCategory requiredCategory; + + public override bool Evaluate(ModifierContext context, ModifierInstance instance) + { + return context.Category == requiredCategory; + } + +#if UNITY_EDITOR + public static CategoryCondition CreateForTest(YachtCategory category) + { + var so = CreateInstance(); + so.requiredCategory = category; + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Conditions/DiceCountCondition.cs b/Assets/Scripts/Modifiers/Conditions/DiceCountCondition.cs new file mode 100644 index 0000000..019e627 --- /dev/null +++ b/Assets/Scripts/Modifiers/Conditions/DiceCountCondition.cs @@ -0,0 +1,40 @@ +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Conditions +{ + [CreateAssetMenu(fileName = "DiceCountCondition", menuName = "YachtDice/Modifiers/Conditions/Dice Count")] + public class DiceCountCondition : ConditionSO + { + [Tooltip("Die face value to count (1-6). 0 = any value.")] + [SerializeField, Range(0, 6)] private int targetValue; + + [Tooltip("Minimum number of dice that must match.")] + [SerializeField] private int minCount = 1; + + public override bool Evaluate(ModifierContext context, ModifierInstance instance) + { + if (context.DiceValues == null) return false; + + int count = 0; + for (int i = 0; i < context.DiceValues.Length; i++) + { + if (targetValue == 0 || context.DiceValues[i] == targetValue) + count++; + } + return count >= minCount; + } + +#if UNITY_EDITOR + public static DiceCountCondition CreateForTest(int targetValue, int minCount) + { + var so = CreateInstance(); + so.targetValue = targetValue; + so.minCount = minCount; + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Conditions/DieValueCondition.cs b/Assets/Scripts/Modifiers/Conditions/DieValueCondition.cs new file mode 100644 index 0000000..d11a799 --- /dev/null +++ b/Assets/Scripts/Modifiers/Conditions/DieValueCondition.cs @@ -0,0 +1,37 @@ +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Conditions +{ + [CreateAssetMenu(fileName = "DieValueCondition", menuName = "YachtDice/Modifiers/Conditions/Die Value")] + public class DieValueCondition : ConditionSO + { + [SerializeField, Range(1, 6)] private int targetValue = 1; + [SerializeField] private int minCount = 1; + + public override bool Evaluate(ModifierContext context, ModifierInstance instance) + { + if (context.DiceValues == null) return false; + + int count = 0; + for (int i = 0; i < context.DiceValues.Length; i++) + { + if (context.DiceValues[i] == targetValue) + count++; + } + return count >= minCount; + } + +#if UNITY_EDITOR + public static DieValueCondition CreateForTest(int targetValue, int minCount = 1) + { + var so = CreateInstance(); + so.targetValue = targetValue; + so.minCount = minCount; + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Conditions/MinScoreCondition.cs b/Assets/Scripts/Modifiers/Conditions/MinScoreCondition.cs new file mode 100644 index 0000000..dee6776 --- /dev/null +++ b/Assets/Scripts/Modifiers/Conditions/MinScoreCondition.cs @@ -0,0 +1,27 @@ +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Conditions +{ + [CreateAssetMenu(fileName = "MinScoreCondition", menuName = "YachtDice/Modifiers/Conditions/Min Score")] + public class MinScoreCondition : ConditionSO + { + [SerializeField] private int minimumBaseScore; + + public override bool Evaluate(ModifierContext context, ModifierInstance instance) + { + return context.BaseScore >= minimumBaseScore; + } + +#if UNITY_EDITOR + public static MinScoreCondition CreateForTest(int minScore) + { + var so = CreateInstance(); + so.minimumBaseScore = minScore; + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Core/ICondition.cs b/Assets/Scripts/Modifiers/Core/ICondition.cs new file mode 100644 index 0000000..3d3ec6c --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/ICondition.cs @@ -0,0 +1,9 @@ +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Core +{ + public interface ICondition + { + bool Evaluate(ModifierContext context, ModifierInstance instance); + } +} diff --git a/Assets/Scripts/Modifiers/Core/IEffect.cs b/Assets/Scripts/Modifiers/Core/IEffect.cs new file mode 100644 index 0000000..87450d8 --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/IEffect.cs @@ -0,0 +1,12 @@ +using Cysharp.Threading.Tasks; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Core +{ + public interface IEffect + { + ModifierPhase Phase { get; } + int Priority { get; } + UniTask Apply(ModifierContext context, ModifierInstance instance); + } +} diff --git a/Assets/Scripts/Modifiers/Core/ModifierContext.cs b/Assets/Scripts/Modifiers/Core/ModifierContext.cs new file mode 100644 index 0000000..bd5addf --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/ModifierContext.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using UnityEngine; +using YachtDice.Modifiers.Runtime; +using YachtDice.Scoring; + +namespace YachtDice.Modifiers.Core +{ + public class ModifierContext + { + // Scoring data + public int BaseScore; + public int FlatBonus; + public float Multiplier = 1f; + public float PostMultiplier = 1f; + public int[] DiceValues; + public YachtCategory Category; + + // Game state (read-only snapshot) + public int CurrentRoll; + public int CurrentTurn; + public int PlayerCurrency; + public IReadOnlyList AllActiveModifiers; + + // Trigger info + public TriggerType Trigger; + + // Side-effect accumulators + public int CurrencyDelta; + + // Debug trace (populated when tracing is enabled) + public List DebugLog; + + public int FinalScore => Mathf.FloorToInt((BaseScore + FlatBonus) * Multiplier * PostMultiplier); + + public ScoreResult ToScoreResult() + { + return new ScoreResult + { + BaseScore = BaseScore, + FlatBonus = FlatBonus, + Multiplier = Multiplier * PostMultiplier, + DiceValues = DiceValues, + Category = Category, + }; + } + + public static ModifierContext CreateForScoring( + int baseScore, + int[] diceValues, + YachtCategory category, + int currentRoll, + int currentTurn, + int playerCurrency, + IReadOnlyList activeModifiers) + { + return new ModifierContext + { + BaseScore = baseScore, + DiceValues = diceValues, + Category = category, + CurrentRoll = currentRoll, + CurrentTurn = currentTurn, + PlayerCurrency = playerCurrency, + AllActiveModifiers = activeModifiers, + }; + } + } +} diff --git a/Assets/Scripts/Modifiers/Core/ModifierPhase.cs b/Assets/Scripts/Modifiers/Core/ModifierPhase.cs new file mode 100644 index 0000000..39f9c9e --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/ModifierPhase.cs @@ -0,0 +1,12 @@ +namespace YachtDice.Modifiers.Core +{ + public enum ModifierPhase + { + Pre = 0, + Additive = 100, + Multiplicative = 200, + PostMultiplicative = 300, + Final = 400, + SideEffect = 500, + } +} diff --git a/Assets/Scripts/Modifiers/Core/ModifierRarity.cs b/Assets/Scripts/Modifiers/Core/ModifierRarity.cs new file mode 100644 index 0000000..0a59982 --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/ModifierRarity.cs @@ -0,0 +1,10 @@ +namespace YachtDice.Modifiers.Core +{ + public enum ModifierRarity + { + Common, + Uncommon, + Rare, + Epic, + } +} diff --git a/Assets/Scripts/Modifiers/Core/TriggerType.cs b/Assets/Scripts/Modifiers/Core/TriggerType.cs new file mode 100644 index 0000000..c04cb81 --- /dev/null +++ b/Assets/Scripts/Modifiers/Core/TriggerType.cs @@ -0,0 +1,18 @@ +namespace YachtDice.Modifiers.Core +{ + public enum TriggerType + { + OnCategoryScored, + OnTurnStart, + OnTurnEnd, + OnRollComplete, + OnDiceLocked, + OnShopOpened, + OnItemPurchased, + OnGameStart, + OnGameEnd, + OnModifierActivated, + OnModifierDeactivated, + OnCurrencyChanged, + } +} diff --git a/Assets/Scripts/Modifiers/Definition/ConditionSO.cs b/Assets/Scripts/Modifiers/Definition/ConditionSO.cs new file mode 100644 index 0000000..4773351 --- /dev/null +++ b/Assets/Scripts/Modifiers/Definition/ConditionSO.cs @@ -0,0 +1,11 @@ +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Definition +{ + public abstract class ConditionSO : ScriptableObject, ICondition + { + public abstract bool Evaluate(ModifierContext context, ModifierInstance instance); + } +} diff --git a/Assets/Scripts/Modifiers/Definition/EffectSO.cs b/Assets/Scripts/Modifiers/Definition/EffectSO.cs new file mode 100644 index 0000000..7cb1177 --- /dev/null +++ b/Assets/Scripts/Modifiers/Definition/EffectSO.cs @@ -0,0 +1,23 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Definition +{ + public abstract class EffectSO : ScriptableObject, IEffect + { + [SerializeField] private ModifierPhase phase = ModifierPhase.Additive; + [SerializeField] private int priority; + + public ModifierPhase Phase => phase; + public int Priority => priority; + + public abstract UniTask Apply(ModifierContext context, ModifierInstance instance); + +#if UNITY_EDITOR + public void SetPhaseForTest(ModifierPhase p) => phase = p; + public void SetPriorityForTest(int p) => priority = p; +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Definition/ModifierBehaviorSO.cs b/Assets/Scripts/Modifiers/Definition/ModifierBehaviorSO.cs new file mode 100644 index 0000000..7c6db6b --- /dev/null +++ b/Assets/Scripts/Modifiers/Definition/ModifierBehaviorSO.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Definition +{ + [CreateAssetMenu(fileName = "NewBehavior", menuName = "YachtDice/Modifiers/Behavior")] + public class ModifierBehaviorSO : ScriptableObject + { + [SerializeField] private TriggerType trigger; + [SerializeField] private List conditions = new(); + [SerializeField] private List effects = new(); + + public TriggerType Trigger => trigger; + public IReadOnlyList Conditions => conditions; + public IReadOnlyList Effects => effects; + + public bool EvaluateConditions(ModifierContext context, ModifierInstance instance) + { + for (int i = 0; i < conditions.Count; i++) + { + if (conditions[i] != null && !conditions[i].Evaluate(context, instance)) + return false; + } + return true; + } + +#if UNITY_EDITOR + public static ModifierBehaviorSO CreateForTest( + TriggerType trigger, + List conditions, + List effects) + { + var so = CreateInstance(); + so.trigger = trigger; + so.conditions = conditions ?? new List(); + so.effects = effects ?? new List(); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Definition/ModifierCatalogSO.cs b/Assets/Scripts/Modifiers/Definition/ModifierCatalogSO.cs new file mode 100644 index 0000000..16d11e3 --- /dev/null +++ b/Assets/Scripts/Modifiers/Definition/ModifierCatalogSO.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace YachtDice.Modifiers.Definition +{ + [CreateAssetMenu(fileName = "ModifierCatalog", menuName = "YachtDice/Modifiers/Catalog")] + public class ModifierCatalogSO : ScriptableObject + { + [SerializeField] private List modifiers = new(); + + public IReadOnlyList All => modifiers; + + public ModifierDefinitionSO FindById(string id) + { + for (int i = 0; i < modifiers.Count; i++) + { + if (modifiers[i] != null && modifiers[i].Id == id) + return modifiers[i]; + } + return null; + } + } +} diff --git a/Assets/Scripts/Modifiers/Definition/ModifierDefinitionSO.cs b/Assets/Scripts/Modifiers/Definition/ModifierDefinitionSO.cs new file mode 100644 index 0000000..f3d3ef9 --- /dev/null +++ b/Assets/Scripts/Modifiers/Definition/ModifierDefinitionSO.cs @@ -0,0 +1,65 @@ +using System.Collections.Generic; +using UnityEngine; +using YachtDice.Modifiers.Core; + +namespace YachtDice.Modifiers.Definition +{ + [CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifiers/Definition")] + public class ModifierDefinitionSO : ScriptableObject + { + [Header("Identity")] + [SerializeField] private string id; + [SerializeField] private string displayName; + [SerializeField, TextArea] private string description; + [SerializeField] private Sprite icon; + [SerializeField] private ModifierRarity rarity; + + [Header("Economy")] + [SerializeField] private int shopPrice; + [SerializeField] private int sellPrice; + + [Header("Durability")] + [SerializeField] private bool hasLimitedUses; + [SerializeField] private int maxUses; + [SerializeField] private int maxStacks = 1; + + [Header("Behaviors")] + [SerializeField] private List behaviors = new(); + + public string Id => id; + public string DisplayName => displayName; + public string Description => description; + public Sprite Icon => icon; + public ModifierRarity Rarity => rarity; + public int ShopPrice => shopPrice; + public int SellPrice => sellPrice; + public bool HasLimitedUses => hasLimitedUses; + public int MaxUses => maxUses; + public int MaxStacks => maxStacks; + public IReadOnlyList Behaviors => behaviors; + +#if UNITY_EDITOR + public static ModifierDefinitionSO CreateForTest( + string id, + List behaviors, + bool hasLimitedUses = false, + int maxUses = 0, + int shopPrice = 100, + int sellPrice = 50, + ModifierRarity rarity = ModifierRarity.Common) + { + var so = CreateInstance(); + so.id = id; + so.displayName = id; + so.description = id; + so.rarity = rarity; + so.shopPrice = shopPrice; + so.sellPrice = sellPrice; + so.hasLimitedUses = hasLimitedUses; + so.maxUses = maxUses; + so.behaviors = behaviors ?? new List(); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Editor/ModifierDefinitionValidator.cs b/Assets/Scripts/Modifiers/Editor/ModifierDefinitionValidator.cs new file mode 100644 index 0000000..af146a3 --- /dev/null +++ b/Assets/Scripts/Modifiers/Editor/ModifierDefinitionValidator.cs @@ -0,0 +1,224 @@ +#if UNITY_EDITOR +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; +using YachtDice.Modifiers.Definition; + +namespace YachtDice.Modifiers.Editor +{ + public static class ModifierDefinitionValidator + { + [MenuItem("YachtDice/Validate All Modifier Definitions")] + public static void ValidateAll() + { + string[] guids = AssetDatabase.FindAssets("t:ModifierDefinitionSO"); + + if (guids.Length == 0) + { + Debug.Log("[ModifierValidator] No ModifierDefinitionSO assets found."); + return; + } + + int errorCount = 0; + int warnCount = 0; + var usedIds = new Dictionary(); // id -> asset path + + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + var def = AssetDatabase.LoadAssetAtPath(path); + + if (def == null) + { + Debug.LogError($"[ModifierValidator] Failed to load asset at {path}"); + errorCount++; + continue; + } + + // ── Id checks ──────────────────────────────────────── + + if (string.IsNullOrWhiteSpace(def.Id)) + { + Debug.LogError($"[ModifierValidator] {path}: Id is empty.", def); + errorCount++; + } + else if (usedIds.TryGetValue(def.Id, out string existingPath)) + { + Debug.LogError($"[ModifierValidator] {path}: Duplicate Id '{def.Id}' (also used by {existingPath}).", def); + errorCount++; + } + else + { + usedIds[def.Id] = path; + } + + // ── Economy checks ─────────────────────────────────── + + if (def.ShopPrice < 0) + { + Debug.LogError($"[ModifierValidator] {path}: ShopPrice is negative ({def.ShopPrice}).", def); + errorCount++; + } + + if (def.SellPrice < 0) + { + Debug.LogError($"[ModifierValidator] {path}: SellPrice is negative ({def.SellPrice}).", def); + errorCount++; + } + + if (def.SellPrice > def.ShopPrice && def.ShopPrice > 0) + { + Debug.LogWarning($"[ModifierValidator] {path}: SellPrice ({def.SellPrice}) > ShopPrice ({def.ShopPrice}). Infinite money exploit?", def); + warnCount++; + } + + // ── Durability checks ──────────────────────────────── + + if (def.HasLimitedUses && def.MaxUses <= 0) + { + Debug.LogError($"[ModifierValidator] {path}: HasLimitedUses is true but MaxUses is {def.MaxUses}.", def); + errorCount++; + } + + if (!def.HasLimitedUses && def.MaxUses > 0) + { + Debug.LogWarning($"[ModifierValidator] {path}: HasLimitedUses is false but MaxUses is {def.MaxUses}. Ignored at runtime.", def); + warnCount++; + } + + if (def.MaxStacks < 1) + { + Debug.LogError($"[ModifierValidator] {path}: MaxStacks must be >= 1 (is {def.MaxStacks}).", def); + errorCount++; + } + + // ── Behavior checks ────────────────────────────────── + + if (def.Behaviors == null || def.Behaviors.Count == 0) + { + Debug.LogWarning($"[ModifierValidator] {path}: No behaviors assigned. Modifier will do nothing.", def); + warnCount++; + continue; + } + + for (int b = 0; b < def.Behaviors.Count; b++) + { + var behavior = def.Behaviors[b]; + + if (behavior == null) + { + Debug.LogError($"[ModifierValidator] {path}: Behavior slot [{b}] is null.", def); + errorCount++; + continue; + } + + // Check for null conditions + if (behavior.Conditions != null) + { + for (int c = 0; c < behavior.Conditions.Count; c++) + { + if (behavior.Conditions[c] == null) + { + Debug.LogWarning($"[ModifierValidator] {path}: Behavior '{behavior.name}' has null condition at slot [{c}].", behavior); + warnCount++; + } + } + } + + // Check for null or empty effects + if (behavior.Effects == null || behavior.Effects.Count == 0) + { + Debug.LogWarning($"[ModifierValidator] {path}: Behavior '{behavior.name}' has no effects.", behavior); + warnCount++; + } + else + { + for (int e = 0; e < behavior.Effects.Count; e++) + { + if (behavior.Effects[e] == null) + { + Debug.LogError($"[ModifierValidator] {path}: Behavior '{behavior.name}' has null effect at slot [{e}].", behavior); + errorCount++; + } + } + } + } + + // ── Display checks ─────────────────────────────────── + + if (string.IsNullOrWhiteSpace(def.DisplayName)) + { + Debug.LogWarning($"[ModifierValidator] {path}: DisplayName is empty.", def); + warnCount++; + } + + if (string.IsNullOrWhiteSpace(def.Description)) + { + Debug.LogWarning($"[ModifierValidator] {path}: Description is empty.", def); + warnCount++; + } + } + + string summary = $"[ModifierValidator] Validated {guids.Length} modifier(s): {errorCount} error(s), {warnCount} warning(s)."; + + if (errorCount > 0) + Debug.LogError(summary); + else if (warnCount > 0) + Debug.LogWarning(summary); + else + Debug.Log(summary); + } + + [MenuItem("YachtDice/Validate Modifier Catalog")] + public static void ValidateCatalog() + { + string[] guids = AssetDatabase.FindAssets("t:ModifierCatalogSO"); + + if (guids.Length == 0) + { + Debug.LogWarning("[ModifierValidator] No ModifierCatalogSO asset found. Create one via Create > YachtDice/Modifiers/Catalog."); + return; + } + + for (int i = 0; i < guids.Length; i++) + { + string path = AssetDatabase.GUIDToAssetPath(guids[i]); + var catalog = AssetDatabase.LoadAssetAtPath(path); + + if (catalog == null) + { + Debug.LogError($"[ModifierValidator] Failed to load catalog at {path}"); + continue; + } + + if (catalog.All == null || catalog.All.Count == 0) + { + Debug.LogWarning($"[ModifierValidator] Catalog at {path} is empty."); + continue; + } + + int nullCount = 0; + var catalogIds = new HashSet(); + + for (int j = 0; j < catalog.All.Count; j++) + { + if (catalog.All[j] == null) + { + nullCount++; + Debug.LogError($"[ModifierValidator] Catalog {path}: Null entry at index [{j}].", catalog); + continue; + } + + string id = catalog.All[j].Id; + if (!catalogIds.Add(id)) + { + Debug.LogError($"[ModifierValidator] Catalog {path}: Duplicate modifier Id '{id}' in catalog.", catalog); + } + } + + Debug.Log($"[ModifierValidator] Catalog {path}: {catalog.All.Count} entries, {nullCount} null."); + } + } + } +} +#endif diff --git a/Assets/Scripts/Modifiers/Effects/AddCurrencyEffect.cs b/Assets/Scripts/Modifiers/Effects/AddCurrencyEffect.cs new file mode 100644 index 0000000..1fa8393 --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/AddCurrencyEffect.cs @@ -0,0 +1,32 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "AddCurrencyEffect", menuName = "YachtDice/Modifiers/Effects/Add Currency")] + public class AddCurrencyEffect : EffectSO + { + [SerializeField] private int amount; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + context.CurrencyDelta += amount * instance.Stacks; + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static AddCurrencyEffect CreateForTest(int amount, + ModifierPhase phase = ModifierPhase.SideEffect, int priority = 0) + { + var so = CreateInstance(); + so.amount = amount; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/AddFlatScoreEffect.cs b/Assets/Scripts/Modifiers/Effects/AddFlatScoreEffect.cs new file mode 100644 index 0000000..1068c74 --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/AddFlatScoreEffect.cs @@ -0,0 +1,31 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "AddFlatScoreEffect", menuName = "YachtDice/Modifiers/Effects/Add Flat Score")] + public class AddFlatScoreEffect : EffectSO + { + [SerializeField] private int value; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + context.FlatBonus += value * instance.Stacks; + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static AddFlatScoreEffect CreateForTest(int value, ModifierPhase phase = ModifierPhase.Additive, int priority = 0) + { + var so = CreateInstance(); + so.value = value; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/AddPerDieEffect.cs b/Assets/Scripts/Modifiers/Effects/AddPerDieEffect.cs new file mode 100644 index 0000000..6c5546d --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/AddPerDieEffect.cs @@ -0,0 +1,46 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "AddPerDieEffect", menuName = "YachtDice/Modifiers/Effects/Add Per Die")] + public class AddPerDieEffect : EffectSO + { + [Tooltip("Points to add per matching die.")] + [SerializeField] private int valuePerDie; + + [Tooltip("Die face value to match (1-6). 0 = any/all dice.")] + [SerializeField, Range(0, 6)] private int targetDieValue; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + if (context.DiceValues == null) return UniTask.CompletedTask; + + int count = 0; + for (int i = 0; i < context.DiceValues.Length; i++) + { + if (targetDieValue == 0 || context.DiceValues[i] == targetDieValue) + count++; + } + + context.FlatBonus += valuePerDie * count * instance.Stacks; + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static AddPerDieEffect CreateForTest(int valuePerDie, int targetDieValue = 0, + ModifierPhase phase = ModifierPhase.Additive, int priority = 0) + { + var so = CreateInstance(); + so.valuePerDie = valuePerDie; + so.targetDieValue = targetDieValue; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/ConsumeChargeEffect.cs b/Assets/Scripts/Modifiers/Effects/ConsumeChargeEffect.cs new file mode 100644 index 0000000..74ac2a7 --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/ConsumeChargeEffect.cs @@ -0,0 +1,32 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "ConsumeChargeEffect", menuName = "YachtDice/Modifiers/Effects/Consume Charge")] + public class ConsumeChargeEffect : EffectSO + { + [SerializeField] private int charges = 1; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + instance.ConsumeCharge(charges); + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static ConsumeChargeEffect CreateForTest(int charges = 1, + ModifierPhase phase = ModifierPhase.SideEffect, int priority = 100) + { + var so = CreateInstance(); + so.charges = charges; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/MultiplyPerDieEffect.cs b/Assets/Scripts/Modifiers/Effects/MultiplyPerDieEffect.cs new file mode 100644 index 0000000..62c8f1d --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/MultiplyPerDieEffect.cs @@ -0,0 +1,44 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "MultiplyPerDieEffect", menuName = "YachtDice/Modifiers/Effects/Multiply Per Die")] + public class MultiplyPerDieEffect : EffectSO + { + [Tooltip("Multiplier to apply per matching die.")] + [SerializeField] private float multiplierPerDie = 1f; + + [Tooltip("Die face value to match (1-6). 0 = any/all dice.")] + [SerializeField, Range(0, 6)] private int targetDieValue; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + if (context.DiceValues == null) return UniTask.CompletedTask; + + for (int i = 0; i < context.DiceValues.Length; i++) + { + if (targetDieValue == 0 || context.DiceValues[i] == targetDieValue) + context.Multiplier *= multiplierPerDie; + } + + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static MultiplyPerDieEffect CreateForTest(float multiplierPerDie, int targetDieValue = 0, + ModifierPhase phase = ModifierPhase.Multiplicative, int priority = 0) + { + var so = CreateInstance(); + so.multiplierPerDie = multiplierPerDie; + so.targetDieValue = targetDieValue; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/MultiplyScoreEffect.cs b/Assets/Scripts/Modifiers/Effects/MultiplyScoreEffect.cs new file mode 100644 index 0000000..01738c2 --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/MultiplyScoreEffect.cs @@ -0,0 +1,32 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "MultiplyScoreEffect", menuName = "YachtDice/Modifiers/Effects/Multiply Score")] + public class MultiplyScoreEffect : EffectSO + { + [SerializeField] private float multiplier = 1f; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + context.Multiplier *= Mathf.Pow(multiplier, instance.Stacks); + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static MultiplyScoreEffect CreateForTest(float multiplier, + ModifierPhase phase = ModifierPhase.Multiplicative, int priority = 0) + { + var so = CreateInstance(); + so.multiplier = multiplier; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/Effects/PostMultiplyEffect.cs b/Assets/Scripts/Modifiers/Effects/PostMultiplyEffect.cs new file mode 100644 index 0000000..7314aac --- /dev/null +++ b/Assets/Scripts/Modifiers/Effects/PostMultiplyEffect.cs @@ -0,0 +1,32 @@ +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Effects +{ + [CreateAssetMenu(fileName = "PostMultiplyEffect", menuName = "YachtDice/Modifiers/Effects/Post Multiply")] + public class PostMultiplyEffect : EffectSO + { + [SerializeField] private float postMultiplier = 1f; + + public override UniTask Apply(ModifierContext context, ModifierInstance instance) + { + context.PostMultiplier *= Mathf.Pow(postMultiplier, instance.Stacks); + return UniTask.CompletedTask; + } + +#if UNITY_EDITOR + public static PostMultiplyEffect CreateForTest(float postMultiplier, + ModifierPhase phase = ModifierPhase.PostMultiplicative, int priority = 0) + { + var so = CreateInstance(); + so.postMultiplier = postMultiplier; + so.SetPhaseForTest(phase); + so.SetPriorityForTest(priority); + return so; + } +#endif + } +} diff --git a/Assets/Scripts/Modifiers/ModifierData.cs b/Assets/Scripts/Modifiers/ModifierData.cs deleted file mode 100644 index 90f6e1d..0000000 --- a/Assets/Scripts/Modifiers/ModifierData.cs +++ /dev/null @@ -1,92 +0,0 @@ -using UnityEngine; -using YachtDice.Scoring; - -namespace YachtDice.Modifiers -{ - [CreateAssetMenu(fileName = "NewModifier", menuName = "YachtDice/Modifier Data")] - public sealed class ModifierData : ScriptableObject - { - [SerializeField] private string id; - [SerializeField] private string displayName; - [SerializeField] [TextArea] private string description; - [SerializeField] private ModifierRarity rarity; - [SerializeField] private int shopPrice; - [SerializeField] private int sellPrice; - [SerializeField] private Sprite icon; - - [Header("Effect")] - [SerializeField] private ModifierScope scope; - [SerializeField] private ModifierEffectType effectType; - [SerializeField] private ModifierTarget target; - [SerializeField] private float effectValue; - - [Header("Durability")] - [SerializeField] private ModifierDurability durability; - [SerializeField] private int maxUses; - - public string Id => id; - public string DisplayName => displayName; - public string Description => description; - public ModifierRarity Rarity => rarity; - public int ShopPrice => shopPrice; - public int SellPrice => sellPrice; - public Sprite Icon => icon; - public ModifierScope Scope => scope; - public ModifierEffectType EffectType => effectType; - public ModifierTarget Target => target; - public float EffectValue => effectValue; - public ModifierDurability Durability => durability; - public int MaxUses => maxUses; - - public bool IsAdditive => - effectType == ModifierEffectType.AddPerDieValue || - effectType == ModifierEffectType.AddFlatToFinalScore; - - public bool IsMultiplicative => - effectType == ModifierEffectType.MultiplyPerDieValue || - effectType == ModifierEffectType.MultiplyFinalScore; - - public bool IsCategoryLevel => - effectType == ModifierEffectType.AddPerDieValue || - effectType == ModifierEffectType.MultiplyPerDieValue; - - public bool IsFinalScoreLevel => - effectType == ModifierEffectType.AddFlatToFinalScore || - effectType == ModifierEffectType.MultiplyFinalScore; - - #if UNITY_EDITOR - public static ModifierData CreateForTest( - string id, - ModifierScope scope, - ModifierEffectType effectType, - float effectValue, - int dieValue = 0, - YachtCategory targetCategory = YachtCategory.Ones, - bool hasCategoryFilter = false, - ModifierDurability durability = ModifierDurability.Permanent, - int maxUses = 0, - int shopPrice = 100, - int sellPrice = 50) - { - var data = CreateInstance(); - data.id = id; - data.displayName = id; - data.description = id; - data.scope = scope; - data.effectType = effectType; - data.effectValue = effectValue; - data.target = new ModifierTarget - { - DieValue = dieValue, - TargetCategory = targetCategory, - HasCategoryFilter = hasCategoryFilter - }; - data.durability = durability; - data.maxUses = maxUses; - data.shopPrice = shopPrice; - data.sellPrice = sellPrice; - return data; - } - #endif - } -} diff --git a/Assets/Scripts/Modifiers/ModifierEffect.cs b/Assets/Scripts/Modifiers/ModifierEffect.cs deleted file mode 100644 index 7dad503..0000000 --- a/Assets/Scripts/Modifiers/ModifierEffect.cs +++ /dev/null @@ -1,59 +0,0 @@ -using System.Collections.Generic; -using YachtDice.Scoring; - -namespace YachtDice.Modifiers -{ - public delegate void ModifierHandler(ModifierData data, ref ScoreResult result); - - public static class ModifierEffect - { - private static readonly Dictionary Handlers = new() - { - { ModifierEffectType.AddPerDieValue, ApplyAddPerDieValue }, - { ModifierEffectType.AddFlatToFinalScore, ApplyAddFlat }, - { ModifierEffectType.MultiplyPerDieValue, ApplyMultiplyPerDieValue }, - { ModifierEffectType.MultiplyFinalScore, ApplyMultiplyFinal } - }; - - public static void Apply(ModifierData data, ref ScoreResult result) - { - if (Handlers.TryGetValue(data.EffectType, out var handler)) - handler(data, ref result); - } - - private static void ApplyAddPerDieValue(ModifierData data, ref ScoreResult result) - { - int targetValue = data.Target.DieValue; - int count = 0; - - for (int i = 0; i < result.DiceValues.Length; i++) - { - if (targetValue == 0 || result.DiceValues[i] == targetValue) - count++; - } - - result.FlatBonus += (int)(data.EffectValue * count); - } - - private static void ApplyAddFlat(ModifierData data, ref ScoreResult result) - { - result.FlatBonus += (int)data.EffectValue; - } - - private static void ApplyMultiplyPerDieValue(ModifierData data, ref ScoreResult result) - { - int targetValue = data.Target.DieValue; - - for (int i = 0; i < result.DiceValues.Length; i++) - { - if (targetValue == 0 || result.DiceValues[i] == targetValue) - result.Multiplier *= data.EffectValue; - } - } - - private static void ApplyMultiplyFinal(ModifierData data, ref ScoreResult result) - { - result.Multiplier *= data.EffectValue; - } - } -} diff --git a/Assets/Scripts/Modifiers/ModifierEnums.cs b/Assets/Scripts/Modifiers/ModifierEnums.cs deleted file mode 100644 index f49207f..0000000 --- a/Assets/Scripts/Modifiers/ModifierEnums.cs +++ /dev/null @@ -1,30 +0,0 @@ -namespace YachtDice.Modifiers -{ - public enum ModifierScope - { - SelectedCategory, - AnyCategoryClosed - } - - public enum ModifierEffectType - { - AddPerDieValue, - AddFlatToFinalScore, - MultiplyPerDieValue, - MultiplyFinalScore - } - - public enum ModifierDurability - { - Permanent, - LimitedUses - } - - public enum ModifierRarity - { - Common, - Uncommon, - Rare, - Epic - } -} diff --git a/Assets/Scripts/Modifiers/ModifierPipeline.cs b/Assets/Scripts/Modifiers/ModifierPipeline.cs deleted file mode 100644 index 8beb99e..0000000 --- a/Assets/Scripts/Modifiers/ModifierPipeline.cs +++ /dev/null @@ -1,66 +0,0 @@ -using System.Collections.Generic; -using YachtDice.Scoring; - -namespace YachtDice.Modifiers -{ - public static class ModifierPipeline - { - // Application order (explicit): - // 1. Category-level additive (AddPerDieValue) - // 2. Category-level multiplicative (MultiplyPerDieValue) - // 3. Final-score additive (AddFlatToFinalScore) - // 4. Final-score multiplicative (MultiplyFinalScore) - - public static void Apply( - IReadOnlyList activeModifiers, - ref ScoreResult result, - ModifierScope currentScope) - { - if (activeModifiers == null) return; - - // Pass 1: Category-level additive - for (int i = 0; i < activeModifiers.Count; i++) - { - var mod = activeModifiers[i]; - if (!ShouldApply(mod, ref result, currentScope)) continue; - if (mod.IsCategoryLevel && mod.IsAdditive) - ModifierEffect.Apply(mod, ref result); - } - - // Pass 2: Category-level multiplicative - for (int i = 0; i < activeModifiers.Count; i++) - { - var mod = activeModifiers[i]; - if (!ShouldApply(mod, ref result, currentScope)) continue; - if (mod.IsCategoryLevel && mod.IsMultiplicative) - ModifierEffect.Apply(mod, ref result); - } - - // Pass 3: Final-score additive - for (int i = 0; i < activeModifiers.Count; i++) - { - var mod = activeModifiers[i]; - if (!ShouldApply(mod, ref result, currentScope)) continue; - if (mod.IsFinalScoreLevel && mod.IsAdditive) - ModifierEffect.Apply(mod, ref result); - } - - // Pass 4: Final-score multiplicative - for (int i = 0; i < activeModifiers.Count; i++) - { - var mod = activeModifiers[i]; - if (!ShouldApply(mod, ref result, currentScope)) continue; - if (mod.IsFinalScoreLevel && mod.IsMultiplicative) - ModifierEffect.Apply(mod, ref result); - } - } - - private static bool ShouldApply(ModifierData mod, ref ScoreResult result, ModifierScope currentScope) - { - if (mod == null) return false; - if (mod.Scope != currentScope) return false; - if (mod.Target.HasCategoryFilter && mod.Target.TargetCategory != result.Category) return false; - return true; - } - } -} diff --git a/Assets/Scripts/Modifiers/ModifierRuntime.cs b/Assets/Scripts/Modifiers/ModifierRuntime.cs deleted file mode 100644 index b559663..0000000 --- a/Assets/Scripts/Modifiers/ModifierRuntime.cs +++ /dev/null @@ -1,37 +0,0 @@ -using System; - -namespace YachtDice.Modifiers -{ - [Serializable] - public class ModifierRuntime - { - public string ModifierId; - public bool IsActive; - public int RemainingUses; - - [NonSerialized] public ModifierData Data; - - public bool IsExpired => Data != null && - Data.Durability == ModifierDurability.LimitedUses && - RemainingUses <= 0; - - public void ConsumeUse() - { - if (Data == null) return; - if (Data.Durability != ModifierDurability.LimitedUses) return; - - RemainingUses--; - } - - public static ModifierRuntime Create(ModifierData data) - { - return new ModifierRuntime - { - ModifierId = data.Id, - IsActive = false, - RemainingUses = data.Durability == ModifierDurability.LimitedUses ? data.MaxUses : -1, - Data = data - }; - } - } -} diff --git a/Assets/Scripts/Modifiers/ModifierTarget.cs b/Assets/Scripts/Modifiers/ModifierTarget.cs deleted file mode 100644 index 8a9d34a..0000000 --- a/Assets/Scripts/Modifiers/ModifierTarget.cs +++ /dev/null @@ -1,20 +0,0 @@ -using System; -using UnityEngine; -using YachtDice.Scoring; - -namespace YachtDice.Modifiers -{ - [Serializable] - public struct ModifierTarget - { - [Tooltip("Die face value (1-6). 0 = any/all dice.")] - [Range(0, 6)] - public int DieValue; - - [Tooltip("Category this modifier targets.")] - public YachtCategory TargetCategory; - - [Tooltip("If true, TargetCategory is used as a filter.")] - public bool HasCategoryFilter; - } -} diff --git a/Assets/Scripts/Modifiers/Pipeline/ModifierComparer.cs b/Assets/Scripts/Modifiers/Pipeline/ModifierComparer.cs new file mode 100644 index 0000000..dd7f61f --- /dev/null +++ b/Assets/Scripts/Modifiers/Pipeline/ModifierComparer.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Pipeline +{ + public struct EffectEntry + { + public EffectSO Effect; + public ModifierInstance Instance; + } + + public class ModifierComparer : IComparer + { + public static readonly ModifierComparer Default = new(); + + public int Compare(EffectEntry a, EffectEntry b) + { + int cmp = a.Effect.Phase.CompareTo(b.Effect.Phase); + if (cmp != 0) return cmp; + + cmp = a.Effect.Priority.CompareTo(b.Effect.Priority); + if (cmp != 0) return cmp; + + return string.Compare(a.Instance.Definition.Id, b.Instance.Definition.Id, StringComparison.Ordinal); + } + } +} diff --git a/Assets/Scripts/Modifiers/Pipeline/ModifierPipeline.cs b/Assets/Scripts/Modifiers/Pipeline/ModifierPipeline.cs new file mode 100644 index 0000000..5f09d77 --- /dev/null +++ b/Assets/Scripts/Modifiers/Pipeline/ModifierPipeline.cs @@ -0,0 +1,131 @@ +using System.Collections.Generic; +using Cysharp.Threading.Tasks; +using UnityEngine; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; + +namespace YachtDice.Modifiers.Pipeline +{ + public class ModifierPipeline + { + private readonly ModifierRegistry registry; + private readonly List effectBuffer = new(); + private bool isExecuting; + private readonly Queue<(TriggerType trigger, ModifierContext context)> deferredQueue = new(); + + private const int MaxRecursionDepth = 1; + private int currentDepth; + + public bool TracingEnabled { get; set; } +#if UNITY_EDITOR || DEVELOPMENT_BUILD + = true; +#endif + + public ModifierPipeline(ModifierRegistry registry) + { + this.registry = registry; + } + + public async UniTask Execute(TriggerType trigger, ModifierContext context) + { + if (isExecuting) + { + if (currentDepth >= MaxRecursionDepth) + { + Debug.LogWarning($"[ModifierPipeline] Max recursion depth reached for trigger {trigger}. Dropping."); + return context; + } + + currentDepth++; + } + + isExecuting = true; + context.Trigger = trigger; + + PipelineTrace trace = null; + if (TracingEnabled) + { + trace = new PipelineTrace { Trigger = trigger }; + context.DebugLog ??= new List(); + } + + effectBuffer.Clear(); + + // Snapshot active modifiers to avoid modification during iteration + var activeSnapshot = registry.Active; + + // Gather eligible effects + for (int i = 0; i < activeSnapshot.Count; i++) + { + var inst = activeSnapshot[i]; + var behaviors = inst.Definition.Behaviors; + + for (int b = 0; b < behaviors.Count; b++) + { + var behavior = behaviors[b]; + if (behavior == null) continue; + if (behavior.Trigger != trigger) continue; + + // Evaluate conditions with tracing + bool passed = true; + string failedCondition = null; + + var conditions = behavior.Conditions; + for (int c = 0; c < conditions.Count; c++) + { + if (conditions[c] == null) continue; + if (!conditions[c].Evaluate(context, inst)) + { + passed = false; + failedCondition = conditions[c].name; + break; + } + } + + if (trace != null) + trace.AddConditionResult(inst.Definition.Id, behavior.name, passed, failedCondition); + + if (!passed) continue; + + // Collect effects + var effects = behavior.Effects; + for (int e = 0; e < effects.Count; e++) + { + if (effects[e] == null) continue; + effectBuffer.Add(new EffectEntry + { + Effect = effects[e], + Instance = inst, + }); + } + } + } + + // Sort by Phase -> Priority -> Id + effectBuffer.Sort(ModifierComparer.Default); + + // Execute sequentially + for (int i = 0; i < effectBuffer.Count; i++) + { + var entry = effectBuffer[i]; + await entry.Effect.Apply(context, entry.Instance); + + if (trace != null) + trace.AddEffectApplied(entry.Instance.Definition.Id, entry.Effect.name, entry.Effect.Phase); + } + + if (trace != null) + { + string traceStr = trace.ToString(); + context.DebugLog.Add(traceStr); + Debug.Log(traceStr); + } + + isExecuting = false; + currentDepth = 0; + + return context; + } + } +} diff --git a/Assets/Scripts/Modifiers/Pipeline/PipelineTrace.cs b/Assets/Scripts/Modifiers/Pipeline/PipelineTrace.cs new file mode 100644 index 0000000..2df33a8 --- /dev/null +++ b/Assets/Scripts/Modifiers/Pipeline/PipelineTrace.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Text; +using YachtDice.Modifiers.Core; + +namespace YachtDice.Modifiers.Pipeline +{ + public class PipelineTrace + { + public TriggerType Trigger; + public readonly List Entries = new(); + + public struct TraceEntry + { + public string ModifierId; + public string BehaviorName; + public bool ConditionsPassed; + public string FailedCondition; + public string EffectApplied; + public ModifierPhase Phase; + } + + public void AddConditionResult(string modifierId, string behaviorName, bool passed, string failedCondition = null) + { + Entries.Add(new TraceEntry + { + ModifierId = modifierId, + BehaviorName = behaviorName, + ConditionsPassed = passed, + FailedCondition = failedCondition, + }); + } + + public void AddEffectApplied(string modifierId, string effectName, ModifierPhase phase) + { + Entries.Add(new TraceEntry + { + ModifierId = modifierId, + EffectApplied = effectName, + Phase = phase, + ConditionsPassed = true, + }); + } + + public override string ToString() + { + var sb = new StringBuilder(); + sb.AppendLine($"[ModifierPipeline] Trigger: {Trigger}"); + for (int i = 0; i < Entries.Count; i++) + { + var e = Entries[i]; + if (e.EffectApplied != null) + { + sb.AppendLine($" EFFECT [{e.Phase}] {e.ModifierId} -> {e.EffectApplied}"); + } + else if (e.ConditionsPassed) + { + sb.AppendLine($" PASS {e.ModifierId} / {e.BehaviorName}"); + } + else + { + sb.AppendLine($" FAIL {e.ModifierId} / {e.BehaviorName} (failed: {e.FailedCondition})"); + } + } + return sb.ToString(); + } + } +} diff --git a/Assets/Scripts/Modifiers/Runtime/ModifierInstance.cs b/Assets/Scripts/Modifiers/Runtime/ModifierInstance.cs new file mode 100644 index 0000000..0559055 --- /dev/null +++ b/Assets/Scripts/Modifiers/Runtime/ModifierInstance.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using YachtDice.Modifiers.Definition; + +namespace YachtDice.Modifiers.Runtime +{ + public class ModifierInstance + { + public ModifierDefinitionSO Definition { get; } + public bool IsActive { get; set; } + public int RemainingUses { get; set; } + public int Stacks { get; set; } = 1; + public Dictionary CustomState { get; } = new(); + + public bool IsExpired => Definition.HasLimitedUses && RemainingUses <= 0; + + public ModifierInstance(ModifierDefinitionSO definition) + { + Definition = definition; + RemainingUses = definition.HasLimitedUses ? definition.MaxUses : -1; + } + + public void ConsumeCharge(int amount = 1) + { + if (!Definition.HasLimitedUses) return; + RemainingUses -= amount; + if (RemainingUses < 0) RemainingUses = 0; + } + } +} diff --git a/Assets/Scripts/Modifiers/Runtime/ModifierRegistry.cs b/Assets/Scripts/Modifiers/Runtime/ModifierRegistry.cs new file mode 100644 index 0000000..9cc4cf1 --- /dev/null +++ b/Assets/Scripts/Modifiers/Runtime/ModifierRegistry.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using UnityEngine; +using YachtDice.Modifiers.Definition; + +namespace YachtDice.Modifiers.Runtime +{ + public class ModifierRegistry + { + private readonly List instances = new(); + private readonly List activeCache = new(); + private int maxActiveSlots; + private bool activeCacheDirty = true; + + public event Action OnChanged; + public event Action> OnActiveModifiersChanged; + + public IReadOnlyList All => instances; + public int MaxActiveSlots => maxActiveSlots; + + public ModifierRegistry(int maxActiveSlots = 5) + { + this.maxActiveSlots = maxActiveSlots; + } + + public IReadOnlyList Active + { + get + { + if (activeCacheDirty) + RebuildActiveCache(); + return activeCache; + } + } + + public int ActiveCount + { + get + { + int count = 0; + for (int i = 0; i < instances.Count; i++) + if (instances[i].IsActive) count++; + return count; + } + } + + public void SetMaxActiveSlots(int slots) + { + maxActiveSlots = slots; + } + + public ModifierInstance Add(ModifierDefinitionSO definition) + { + var instance = new ModifierInstance(definition); + instances.Add(instance); + activeCacheDirty = true; + OnChanged?.Invoke(); + return instance; + } + + public void Remove(ModifierInstance instance) + { + if (!instances.Contains(instance)) return; + + bool wasActive = instance.IsActive; + instance.IsActive = false; + instances.Remove(instance); + activeCacheDirty = true; + + if (wasActive) + OnActiveModifiersChanged?.Invoke(Active); + + OnChanged?.Invoke(); + } + + public bool TryActivate(ModifierInstance instance) + { + if (instance.IsActive) return false; + if (!instances.Contains(instance)) return false; + if (ActiveCount >= maxActiveSlots) return false; + + instance.IsActive = true; + activeCacheDirty = true; + OnActiveModifiersChanged?.Invoke(Active); + OnChanged?.Invoke(); + return true; + } + + public void Deactivate(ModifierInstance instance) + { + if (!instance.IsActive) return; + + instance.IsActive = false; + activeCacheDirty = true; + OnActiveModifiersChanged?.Invoke(Active); + OnChanged?.Invoke(); + } + + public void ConsumeChargesOnActive() + { + bool changed = false; + + for (int i = instances.Count - 1; i >= 0; i--) + { + var inst = instances[i]; + if (!inst.IsActive) continue; + if (!inst.Definition.HasLimitedUses) continue; + + inst.ConsumeCharge(); + + if (inst.IsExpired) + { + instances.RemoveAt(i); + changed = true; + } + } + + if (changed) + { + activeCacheDirty = true; + OnActiveModifiersChanged?.Invoke(Active); + OnChanged?.Invoke(); + } + } + + public List GetSaveData() + { + var entries = new List(); + for (int i = 0; i < instances.Count; i++) + { + var inst = instances[i]; + var entry = new ModifierSaveEntry + { + ModifierId = inst.Definition.Id, + IsActive = inst.IsActive, + RemainingUses = inst.RemainingUses, + Stacks = inst.Stacks, + }; + + foreach (var kvp in inst.CustomState) + { + entry.CustomState.Add(new CustomStateEntry + { + Key = kvp.Key, + Value = kvp.Value, + }); + } + + entries.Add(entry); + } + return entries; + } + + public void LoadSaveData(List entries, ModifierCatalogSO catalog) + { + instances.Clear(); + activeCacheDirty = true; + + if (entries == null) return; + + for (int i = 0; i < entries.Count; i++) + { + var entry = entries[i]; + var definition = catalog.FindById(entry.ModifierId); + + if (definition == null) + { + Debug.LogWarning($"Modifier '{entry.ModifierId}' not found in catalog, skipping."); + continue; + } + + var instance = new ModifierInstance(definition) + { + IsActive = entry.IsActive, + RemainingUses = entry.RemainingUses, + Stacks = entry.Stacks, + }; + + if (entry.CustomState != null) + { + foreach (var cs in entry.CustomState) + instance.CustomState[cs.Key] = cs.Value; + } + + instances.Add(instance); + } + + OnActiveModifiersChanged?.Invoke(Active); + OnChanged?.Invoke(); + } + + public void Clear() + { + instances.Clear(); + activeCacheDirty = true; + OnActiveModifiersChanged?.Invoke(Active); + OnChanged?.Invoke(); + } + + private void RebuildActiveCache() + { + activeCache.Clear(); + for (int i = 0; i < instances.Count; i++) + { + if (instances[i].IsActive) + activeCache.Add(instances[i]); + } + activeCacheDirty = false; + } + } +} diff --git a/Assets/Scripts/Modifiers/Runtime/ModifierSaveData.cs b/Assets/Scripts/Modifiers/Runtime/ModifierSaveData.cs new file mode 100644 index 0000000..b1c1ce0 --- /dev/null +++ b/Assets/Scripts/Modifiers/Runtime/ModifierSaveData.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; + +namespace YachtDice.Modifiers.Runtime +{ + [Serializable] + public class ModifierSaveEntry + { + public string ModifierId; + public bool IsActive; + public int RemainingUses; + public int Stacks; + public List CustomState = new(); + } + + [Serializable] + public class CustomStateEntry + { + public string Key; + public float Value; + } +} diff --git a/Assets/Scripts/Persistence/SaveData.cs b/Assets/Scripts/Persistence/SaveData.cs index ddcea33..8212af5 100644 --- a/Assets/Scripts/Persistence/SaveData.cs +++ b/Assets/Scripts/Persistence/SaveData.cs @@ -1,21 +1,14 @@ using System; using System.Collections.Generic; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Persistence { [Serializable] public sealed class SaveData { - public int Version = 1; + public int Version = 2; public int Currency; public List OwnedModifiers = new(); } - - [Serializable] - public sealed class ModifierSaveEntry - { - public string ModifierId; - public bool IsActive; - public int RemainingUses; - } } diff --git a/Assets/Scripts/Scoring/ScoringSystem.cs b/Assets/Scripts/Scoring/ScoringSystem.cs index d9eee18..b44a216 100644 --- a/Assets/Scripts/Scoring/ScoringSystem.cs +++ b/Assets/Scripts/Scoring/ScoringSystem.cs @@ -1,7 +1,11 @@ using System; using System.Collections.Generic; +using Cysharp.Threading.Tasks; using UnityEngine; -using YachtDice.Modifiers; +using VContainer; +using YachtDice.Events; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Scoring { @@ -13,7 +17,16 @@ namespace YachtDice.Scoring private readonly Dictionary scorecard = new(); private readonly HashSet usedCategories = new(); - private List activeModifierData = new(); + + private GameEventBus eventBus; + private ModifierRegistry modifierRegistry; + + [Inject] + public void Construct(GameEventBus eventBus, ModifierRegistry modifierRegistry) + { + this.eventBus = eventBus; + this.modifierRegistry = modifierRegistry; + } public bool IsCategoryUsed(YachtCategory category) => usedCategories.Contains(category); @@ -38,19 +51,62 @@ namespace YachtDice.Scoring public bool IsComplete => CategoriesFilledCount >= TotalCategoryCount; - public void SetActiveModifiers(List modifiers) - { - activeModifierData = modifiers ?? new List(); - } - - public IReadOnlyList ActiveModifiers => activeModifierData; - - public ScoreResult PreviewScore(int[] diceValues, YachtCategory category) + public ScoreResult PreviewScore(int[] diceValues, YachtCategory category, + int currentRoll = 0, int currentTurn = 0, int playerCurrency = 0) { int baseScore = CategoryScorer.Calculate(diceValues, category); - ScoreResult result = ScoreResult.Create(baseScore, diceValues, category); - ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.SelectedCategory); + if (eventBus == null || modifierRegistry == null) + return ScoreResult.Create(baseScore, diceValues, category); + + var context = ModifierContext.CreateForScoring( + baseScore, diceValues, category, + currentRoll, currentTurn, playerCurrency, + modifierRegistry.Active); + + eventBus.Fire(TriggerType.OnCategoryScored, context).Forget(); + + return context.ToScoreResult(); + } + + public async UniTask ScoreCategoryAsync(int[] diceValues, YachtCategory category, + int currentRoll, int currentTurn, int playerCurrency) + { + if (usedCategories.Contains(category)) + throw new InvalidOperationException($"Category {category} has already been scored."); + + int baseScore = CategoryScorer.Calculate(diceValues, category); + + ModifierContext context; + if (eventBus != null && modifierRegistry != null) + { + context = ModifierContext.CreateForScoring( + baseScore, diceValues, category, + currentRoll, currentTurn, playerCurrency, + modifierRegistry.Active); + + await eventBus.Fire(TriggerType.OnCategoryScored, context); + } + else + { + context = new ModifierContext + { + BaseScore = baseScore, + DiceValues = diceValues, + Category = category, + }; + } + + var result = context.ToScoreResult(); + int finalScore = result.FinalScore; + scorecard[category] = finalScore; + usedCategories.Add(category); + + OnCategoryScored?.Invoke(category, finalScore); + OnCategoryConfirmed?.Invoke(category, result); + + if (IsComplete) + OnAllCategoriesScored?.Invoke(TotalScore); return result; } @@ -61,10 +117,23 @@ namespace YachtDice.Scoring throw new InvalidOperationException($"Category {category} has already been scored."); int baseScore = CategoryScorer.Calculate(diceValues, category); - ScoreResult result = ScoreResult.Create(baseScore, diceValues, category); - ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.SelectedCategory); - ModifierPipeline.Apply(activeModifierData, ref result, ModifierScope.AnyCategoryClosed); + ModifierContext context = null; + if (eventBus != null && modifierRegistry != null) + { + context = ModifierContext.CreateForScoring( + baseScore, diceValues, category, + 0, 0, 0, + modifierRegistry.Active); + + eventBus.Fire(TriggerType.OnCategoryScored, context).Forget(); + } + + ScoreResult result; + if (context != null) + result = context.ToScoreResult(); + else + result = ScoreResult.Create(baseScore, diceValues, category); int finalScore = result.FinalScore; scorecard[category] = finalScore; diff --git a/Assets/Scripts/Shop/ShopCatalog.cs b/Assets/Scripts/Shop/ShopCatalog.cs deleted file mode 100644 index f4f8e21..0000000 --- a/Assets/Scripts/Shop/ShopCatalog.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; -using YachtDice.Modifiers; - -namespace YachtDice.Shop -{ - [CreateAssetMenu(fileName = "ShopCatalog", menuName = "YachtDice/Shop Catalog")] - public sealed class ShopCatalog : ScriptableObject - { - [SerializeField] private List availableModifiers = new(); - - public IReadOnlyList AvailableModifiers => availableModifiers; - - public ModifierData FindById(string id) - { - for (int i = 0; i < availableModifiers.Count; i++) - { - if (availableModifiers[i] != null && availableModifiers[i].Id == id) - return availableModifiers[i]; - } - return null; - } - } -} diff --git a/Assets/Scripts/Shop/ShopController.cs b/Assets/Scripts/Shop/ShopController.cs index 4f5f4c8..52174d3 100644 --- a/Assets/Scripts/Shop/ShopController.cs +++ b/Assets/Scripts/Shop/ShopController.cs @@ -1,18 +1,18 @@ using UnityEngine; using YachtDice.Economy; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { public class ShopController : MonoBehaviour { - [SerializeField] private ShopCatalog catalog; + [SerializeField] private ModifierCatalogSO catalog; [SerializeField] private ShopView shopView; [SerializeField] private CurrencyBank currencyBank; private ShopModel model; - public ShopCatalog Catalog => catalog; + public ModifierCatalogSO Catalog => catalog; public void Initialize(ShopModel shopModel) { @@ -25,7 +25,7 @@ namespace YachtDice.Shop model.OnItemPurchased += HandleItemPurchased; - shopView.Populate(catalog.AvailableModifiers, model); + shopView.Populate(catalog.All, model); shopView.UpdateCurrencyDisplay(currencyBank != null ? currencyBank.Balance : 0); } @@ -41,20 +41,20 @@ namespace YachtDice.Shop model.OnItemPurchased -= HandleItemPurchased; } - private void HandleBuyClicked(ModifierData data) + private void HandleBuyClicked(ModifierDefinitionSO def) { - model.TryPurchase(data); + model.TryPurchase(def); } private void HandleCurrencyChanged(int newBalance) { shopView.UpdateCurrencyDisplay(newBalance); - shopView.RefreshStates(catalog.AvailableModifiers, model); + shopView.RefreshStates(catalog.All, model); } - private void HandleItemPurchased(ModifierData data) + private void HandleItemPurchased(ModifierDefinitionSO def) { - shopView.RefreshStates(catalog.AvailableModifiers, model); + shopView.RefreshStates(catalog.All, model); } } } diff --git a/Assets/Scripts/Shop/ShopItemView.cs b/Assets/Scripts/Shop/ShopItemView.cs index 05d5e55..25a7d8a 100644 --- a/Assets/Scripts/Shop/ShopItemView.cs +++ b/Assets/Scripts/Shop/ShopItemView.cs @@ -2,7 +2,8 @@ using System; using TMPro; using UnityEngine; using UnityEngine.UI; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { @@ -23,9 +24,9 @@ namespace YachtDice.Shop [SerializeField] private Color rareColor = new(0.4f, 0.6f, 1f); [SerializeField] private Color epicColor = new(0.8f, 0.4f, 1f); - private ModifierData data; + private ModifierDefinitionSO data; - public event Action OnBuyClicked; + public event Action OnBuyClicked; private void Awake() { @@ -33,9 +34,9 @@ namespace YachtDice.Shop buyButton.onClick.AddListener(() => OnBuyClicked?.Invoke(data)); } - public void Setup(ModifierData modifierData, ShopItemState state) + public void Setup(ModifierDefinitionSO modifierDef, ShopItemState state) { - data = modifierData; + data = modifierDef; if (nameText != null) nameText.text = data.DisplayName; if (descriptionText != null) descriptionText.text = data.Description; diff --git a/Assets/Scripts/Shop/ShopModel.cs b/Assets/Scripts/Shop/ShopModel.cs index 102d274..726de85 100644 --- a/Assets/Scripts/Shop/ShopModel.cs +++ b/Assets/Scripts/Shop/ShopModel.cs @@ -2,7 +2,7 @@ using System; using System.Collections.Generic; using YachtDice.Economy; using YachtDice.Inventory; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { @@ -12,7 +12,7 @@ namespace YachtDice.Shop private readonly InventoryModel inventoryModel; private readonly HashSet purchasedPermanentIds = new(); - public event Action OnItemPurchased; + public event Action OnItemPurchased; public ShopModel(CurrencyBank currencyBank, InventoryModel inventoryModel) { @@ -20,25 +20,24 @@ namespace YachtDice.Shop this.inventoryModel = inventoryModel; } - public bool CanPurchase(ModifierData modifier) + public bool CanPurchase(ModifierDefinitionSO modifier) { if (modifier == null) return false; if (!currencyBank.CanAfford(modifier.ShopPrice)) return false; - if (modifier.Durability == ModifierDurability.Permanent && - purchasedPermanentIds.Contains(modifier.Id)) + if (!modifier.HasLimitedUses && purchasedPermanentIds.Contains(modifier.Id)) return false; return true; } - public bool TryPurchase(ModifierData modifier) + public bool TryPurchase(ModifierDefinitionSO modifier) { if (!CanPurchase(modifier)) return false; if (!currencyBank.Spend(modifier.ShopPrice)) return false; - if (modifier.Durability == ModifierDurability.Permanent) + if (!modifier.HasLimitedUses) purchasedPermanentIds.Add(modifier.Id); inventoryModel.AddModifier(modifier); @@ -48,18 +47,17 @@ namespace YachtDice.Shop public bool IsPermanentOwned(string modifierId) => purchasedPermanentIds.Contains(modifierId); - public ShopItemState GetItemState(ModifierData modifier) + public ShopItemState GetItemState(ModifierDefinitionSO modifier) { if (modifier == null) return ShopItemState.TooExpensive; - if (modifier.Durability == ModifierDurability.Permanent && - purchasedPermanentIds.Contains(modifier.Id)) + if (!modifier.HasLimitedUses && purchasedPermanentIds.Contains(modifier.Id)) return ShopItemState.Owned; if (!currencyBank.CanAfford(modifier.ShopPrice)) return ShopItemState.TooExpensive; - return modifier.Durability == ModifierDurability.LimitedUses + return modifier.HasLimitedUses ? ShopItemState.RepurchaseAvailable : ShopItemState.Available; } @@ -79,6 +77,6 @@ namespace YachtDice.Shop Available, TooExpensive, Owned, - RepurchaseAvailable + RepurchaseAvailable, } } diff --git a/Assets/Scripts/Shop/ShopView.cs b/Assets/Scripts/Shop/ShopView.cs index a888a62..3886457 100644 --- a/Assets/Scripts/Shop/ShopView.cs +++ b/Assets/Scripts/Shop/ShopView.cs @@ -3,7 +3,7 @@ using System.Collections.Generic; using TMPro; using UnityEngine; using UnityEngine.UI; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; namespace YachtDice.Shop { @@ -16,7 +16,7 @@ namespace YachtDice.Shop private readonly List spawnedItems = new(); - public event Action OnBuyClicked; + public event Action OnBuyClicked; private void Awake() { @@ -34,24 +34,24 @@ namespace YachtDice.Shop public void Hide() => gameObject.SetActive(false); public bool IsVisible => gameObject.activeSelf; - public void Populate(IReadOnlyList catalog, ShopModel model) + public void Populate(IReadOnlyList catalog, ShopModel model) { ClearItems(); for (int i = 0; i < catalog.Count; i++) { - var data = catalog[i]; - if (data == null) continue; + var def = catalog[i]; + if (def == null) continue; var item = Instantiate(itemPrefab, itemContainer); - var state = model.GetItemState(data); - item.Setup(data, state); + var state = model.GetItemState(def); + item.Setup(def, state); item.OnBuyClicked += HandleBuy; spawnedItems.Add(item); } } - public void RefreshStates(IReadOnlyList catalog, ShopModel model) + public void RefreshStates(IReadOnlyList catalog, ShopModel model) { for (int i = 0; i < spawnedItems.Count && i < catalog.Count; i++) { @@ -76,6 +76,6 @@ namespace YachtDice.Shop spawnedItems.Clear(); } - private void HandleBuy(ModifierData data) => OnBuyClicked?.Invoke(data); + private void HandleBuy(ModifierDefinitionSO def) => OnBuyClicked?.Invoke(def); } } diff --git a/Assets/Scripts/Tests/Editor/InventoryModelTests.cs b/Assets/Scripts/Tests/Editor/InventoryModelTests.cs index 095ac40..41676a3 100644 --- a/Assets/Scripts/Tests/Editor/InventoryModelTests.cs +++ b/Assets/Scripts/Tests/Editor/InventoryModelTests.cs @@ -1,32 +1,34 @@ using NUnit.Framework; using UnityEngine; using YachtDice.Inventory; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Tests { public class InventoryModelTests { + private ModifierRegistry registry; private InventoryModel inventory; [SetUp] public void SetUp() { - inventory = new InventoryModel(3); + registry = new ModifierRegistry(3); + inventory = new InventoryModel(registry); } - private ModifierData CreateTestData(string id = "test", - ModifierDurability durability = ModifierDurability.Permanent, int maxUses = 0) + private ModifierDefinitionSO CreateTestDef(string id = "test", + bool hasLimitedUses = false, int maxUses = 0) { - return ModifierData.CreateForTest(id, ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, - durability: durability, maxUses: maxUses); + return ModifierDefinitionSO.CreateForTest(id, null, + hasLimitedUses: hasLimitedUses, maxUses: maxUses); } [Test] public void AddModifier_IncreasesCount() { - inventory.AddModifier(CreateTestData()); + inventory.AddModifier(CreateTestDef()); Assert.AreEqual(1, inventory.OwnedModifiers.Count); } @@ -34,7 +36,7 @@ namespace YachtDice.Tests [Test] public void TryActivate_SucceedsWithinSlotLimit() { - inventory.AddModifier(CreateTestData("a")); + inventory.AddModifier(CreateTestDef("a")); var mod = inventory.OwnedModifiers[0]; bool result = inventory.TryActivate(mod); @@ -49,11 +51,11 @@ namespace YachtDice.Tests { for (int i = 0; i < 3; i++) { - inventory.AddModifier(CreateTestData($"m{i}")); + inventory.AddModifier(CreateTestDef($"m{i}")); inventory.TryActivate(inventory.OwnedModifiers[i]); } - inventory.AddModifier(CreateTestData("extra")); + inventory.AddModifier(CreateTestDef("extra")); var extra = inventory.OwnedModifiers[3]; bool result = inventory.TryActivate(extra); @@ -65,7 +67,7 @@ namespace YachtDice.Tests [Test] public void Deactivate_FreesSlot() { - inventory.AddModifier(CreateTestData()); + inventory.AddModifier(CreateTestDef()); var mod = inventory.OwnedModifiers[0]; inventory.TryActivate(mod); @@ -78,7 +80,7 @@ namespace YachtDice.Tests [Test] public void RemoveModifier_DeactivatesAndRemoves() { - inventory.AddModifier(CreateTestData()); + inventory.AddModifier(CreateTestDef()); var mod = inventory.OwnedModifiers[0]; inventory.TryActivate(mod); @@ -91,7 +93,7 @@ namespace YachtDice.Tests [Test] public void ConsumeUseOnActive_DecrementsUses() { - inventory.AddModifier(CreateTestData("ltd", ModifierDurability.LimitedUses, 3)); + inventory.AddModifier(CreateTestDef("ltd", hasLimitedUses: true, maxUses: 3)); var mod = inventory.OwnedModifiers[0]; inventory.TryActivate(mod); @@ -103,7 +105,7 @@ namespace YachtDice.Tests [Test] public void ConsumeUseOnActive_RemovesExpired() { - inventory.AddModifier(CreateTestData("ltd", ModifierDurability.LimitedUses, 1)); + inventory.AddModifier(CreateTestDef("ltd", hasLimitedUses: true, maxUses: 1)); var mod = inventory.OwnedModifiers[0]; inventory.TryActivate(mod); @@ -115,7 +117,7 @@ namespace YachtDice.Tests [Test] public void ConsumeUseOnActive_IgnoresPermanent() { - inventory.AddModifier(CreateTestData("perm", ModifierDurability.Permanent)); + inventory.AddModifier(CreateTestDef("perm")); var mod = inventory.OwnedModifiers[0]; inventory.TryActivate(mod); @@ -126,13 +128,13 @@ namespace YachtDice.Tests } [Test] - public void GetActiveModifierData_ReturnsOnlyActive() + public void GetActiveModifierDefinitions_ReturnsOnlyActive() { - inventory.AddModifier(CreateTestData("a")); - inventory.AddModifier(CreateTestData("b")); + inventory.AddModifier(CreateTestDef("a")); + inventory.AddModifier(CreateTestDef("b")); inventory.TryActivate(inventory.OwnedModifiers[0]); - var active = inventory.GetActiveModifierData(); + var active = inventory.GetActiveModifierDefinitions(); Assert.AreEqual(1, active.Count); } @@ -150,7 +152,7 @@ namespace YachtDice.Tests { bool fired = false; inventory.OnActiveModifiersChanged += _ => fired = true; - inventory.AddModifier(CreateTestData()); + inventory.AddModifier(CreateTestDef()); inventory.TryActivate(inventory.OwnedModifiers[0]); diff --git a/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs b/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs index 69b84a5..c09ae42 100644 --- a/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs +++ b/Assets/Scripts/Tests/Editor/ModifierEffectTests.cs @@ -1,94 +1,244 @@ using NUnit.Framework; using UnityEngine; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Effects; +using YachtDice.Modifiers.Runtime; using YachtDice.Scoring; namespace YachtDice.Tests { public class ModifierEffectTests { - private static ModifierData CreateData( - ModifierEffectType effectType, float effectValue, - int dieValue = 0, ModifierScope scope = ModifierScope.SelectedCategory) + private ModifierInstance CreateInstance(string id = "test") { - return ModifierData.CreateForTest("test", scope, effectType, effectValue, dieValue); + var def = ModifierDefinitionSO.CreateForTest(id, null); + return new ModifierInstance(def); + } + + private ModifierContext CreateContext(int baseScore, int[] dice, YachtCategory category) + { + return new ModifierContext + { + BaseScore = baseScore, + DiceValues = dice, + Category = category, + }; + } + + // ── AddPerDieEffect ───────────────────────────────────────── + + [Test] + public void AddPerDieEffect_CountsMatchingDice() + { + var effect = AddPerDieEffect.CreateForTest(10, targetDieValue: 1); + var ctx = CreateContext(5, new[] { 1, 1, 3, 4, 1 }, YachtCategory.Ones); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(30, ctx.FlatBonus); // 10 * 3 matching dice } [Test] - public void AddPerDieValue_CountsMatchingDice() + public void AddPerDieEffect_ZeroTarget_CountsAllDice() { - var data = CreateData(ModifierEffectType.AddPerDieValue, 10f, dieValue: 1); - var result = ScoreResult.Create(5, new[] { 1, 1, 3, 4, 1 }, YachtCategory.Ones); + var effect = AddPerDieEffect.CreateForTest(2, targetDieValue: 0); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(30, result.FlatBonus); + Assert.AreEqual(10, ctx.FlatBonus); // 2 * 5 dice } [Test] - public void AddPerDieValue_ZeroTarget_CountsAllDice() + public void AddPerDieEffect_NoMatches_ZeroBonus() { - var data = CreateData(ModifierEffectType.AddPerDieValue, 2f, dieValue: 0); - var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var effect = AddPerDieEffect.CreateForTest(10, targetDieValue: 6); + var ctx = CreateContext(5, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(10, result.FlatBonus); + Assert.AreEqual(0, ctx.FlatBonus); } [Test] - public void AddPerDieValue_NoMatches_ZeroBonus() + public void AddPerDieEffect_ScalesWithStacks() { - var data = CreateData(ModifierEffectType.AddPerDieValue, 10f, dieValue: 6); - var result = ScoreResult.Create(5, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var effect = AddPerDieEffect.CreateForTest(10, targetDieValue: 1); + var ctx = CreateContext(5, new[] { 1, 1, 3, 4, 1 }, YachtCategory.Ones); + var inst = CreateInstance(); + inst.Stacks = 2; - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(60, ctx.FlatBonus); // 10 * 3 * 2 stacks + } + + // ── AddFlatScoreEffect ────────────────────────────────────── + + [Test] + public void AddFlatScoreEffect_AddsFlat() + { + var effect = AddFlatScoreEffect.CreateForTest(15); + var ctx = CreateContext(25, new[] { 3, 3, 2, 2, 2 }, YachtCategory.FullHouse); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(15, ctx.FlatBonus); } [Test] - public void AddFlatToFinalScore_AddsFlat() + public void AddFlatScoreEffect_ScalesWithStacks() { - var data = CreateData(ModifierEffectType.AddFlatToFinalScore, 15f); - var result = ScoreResult.Create(25, new[] { 3, 3, 2, 2, 2 }, YachtCategory.FullHouse); + var effect = AddFlatScoreEffect.CreateForTest(15); + var ctx = CreateContext(25, new[] { 3, 3, 2, 2, 2 }, YachtCategory.FullHouse); + var inst = CreateInstance(); + inst.Stacks = 3; - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(15, result.FlatBonus); + Assert.AreEqual(45, ctx.FlatBonus); // 15 * 3 stacks + } + + // ── MultiplyPerDieEffect ──────────────────────────────────── + + [Test] + public void MultiplyPerDieEffect_MultipliesPerMatch() + { + var effect = MultiplyPerDieEffect.CreateForTest(2f, targetDieValue: 6); + var ctx = CreateContext(18, new[] { 6, 6, 6, 1, 2 }, YachtCategory.Sixes); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(8f, ctx.Multiplier, 0.001f); // 1 * 2 * 2 * 2 = 8 } [Test] - public void MultiplyPerDieValue_MultipliesPerMatch() + public void MultiplyPerDieEffect_NoMatches_MultiplierUnchanged() { - var data = CreateData(ModifierEffectType.MultiplyPerDieValue, 2f, dieValue: 6); - var result = ScoreResult.Create(18, new[] { 6, 6, 6, 1, 2 }, YachtCategory.Sixes); + var effect = MultiplyPerDieEffect.CreateForTest(3f, targetDieValue: 6); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(8f, result.Multiplier); // 1 * 2 * 2 * 2 = 8 + Assert.AreEqual(1f, ctx.Multiplier); + } + + // ── MultiplyScoreEffect ───────────────────────────────────── + + [Test] + public void MultiplyScoreEffect_MultipliesOnce() + { + var effect = MultiplyScoreEffect.CreateForTest(1.5f); + var ctx = CreateContext(50, new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(1.5f, ctx.Multiplier, 0.001f); } [Test] - public void MultiplyPerDieValue_NoMatches_MultiplierUnchanged() + public void MultiplyScoreEffect_ScalesWithStacks() { - var data = CreateData(ModifierEffectType.MultiplyPerDieValue, 3f, dieValue: 6); - var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var effect = MultiplyScoreEffect.CreateForTest(2f); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); + inst.Stacks = 3; - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(1f, result.Multiplier); + // Pow(2, 3) = 8 + Assert.AreEqual(8f, ctx.Multiplier, 0.001f); + } + + // ── PostMultiplyEffect ────────────────────────────────────── + + [Test] + public void PostMultiplyEffect_MultipliesPostMultiplier() + { + var effect = PostMultiplyEffect.CreateForTest(2f); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(2f, ctx.PostMultiplier, 0.001f); + } + + // ── AddCurrencyEffect ─────────────────────────────────────── + + [Test] + public void AddCurrencyEffect_AddsToCurrencyDelta() + { + var effect = AddCurrencyEffect.CreateForTest(25); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(25, ctx.CurrencyDelta); } [Test] - public void MultiplyFinalScore_MultipliesOnce() + public void AddCurrencyEffect_ScalesWithStacks() { - var data = CreateData(ModifierEffectType.MultiplyFinalScore, 1.5f); - var result = ScoreResult.Create(50, new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); + var effect = AddCurrencyEffect.CreateForTest(25); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); + inst.Stacks = 2; - ModifierEffect.Apply(data, ref result); + effect.Apply(ctx, inst).GetAwaiter().GetResult(); - Assert.AreEqual(1.5f, result.Multiplier, 0.001f); + Assert.AreEqual(50, ctx.CurrencyDelta); // 25 * 2 stacks + } + + // ── ConsumeChargeEffect ───────────────────────────────────── + + [Test] + public void ConsumeChargeEffect_DecrementsRemainingUses() + { + var effect = ConsumeChargeEffect.CreateForTest(1); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var def = ModifierDefinitionSO.CreateForTest("limited", null, + hasLimitedUses: true, maxUses: 3); + var inst = new ModifierInstance(def); + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(2, inst.RemainingUses); + } + + [Test] + public void ConsumeChargeEffect_IgnoresPermanent() + { + var effect = ConsumeChargeEffect.CreateForTest(1); + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var inst = CreateInstance(); // permanent (no limited uses) + + effect.Apply(ctx, inst).GetAwaiter().GetResult(); + + Assert.AreEqual(-1, inst.RemainingUses); // unchanged + } + + // ── FinalScore Integration ────────────────────────────────── + + [Test] + public void FinalScore_CombinesBaseAndFlatAndMultiplier() + { + var ctx = CreateContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + ctx.FlatBonus = 5; + ctx.Multiplier = 2f; + ctx.PostMultiplier = 1.5f; + + // FinalScore = floor((10 + 5) * 2 * 1.5) = floor(45) = 45 + Assert.AreEqual(45, ctx.FinalScore); } } } diff --git a/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs b/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs index 6b33ce9..4d8fb74 100644 --- a/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs +++ b/Assets/Scripts/Tests/Editor/ModifierPipelineTests.cs @@ -1,141 +1,336 @@ using System.Collections.Generic; using NUnit.Framework; using UnityEngine; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Conditions; +using YachtDice.Modifiers.Core; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Effects; +using YachtDice.Modifiers.Pipeline; +using YachtDice.Modifiers.Runtime; using YachtDice.Scoring; namespace YachtDice.Tests { public class ModifierPipelineTests { - [Test] - public void Apply_AdditiveBeforeMultiplicative() + private ModifierRegistry registry; + private ModifierPipeline pipeline; + + [SetUp] + public void SetUp() { - var addMod = ModifierData.CreateForTest("add", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f); - var mulMod = ModifierData.CreateForTest("mul", ModifierScope.SelectedCategory, - ModifierEffectType.MultiplyFinalScore, 2f); + registry = new ModifierRegistry(10); + pipeline = new ModifierPipeline(registry); + pipeline.TracingEnabled = false; // disable debug logs during tests + } - var modifiers = new List { mulMod, addMod }; - var result = ScoreResult.Create(20, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + private ModifierDefinitionSO CreateDef(string id, + TriggerType trigger, + List conditions, + List effects) + { + var behavior = ModifierBehaviorSO.CreateForTest(trigger, conditions, effects); + return ModifierDefinitionSO.CreateForTest(id, + new List { behavior }); + } - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + private void RegisterAndActivate(ModifierDefinitionSO def) + { + var inst = registry.Add(def); + registry.TryActivate(inst); + } + + private ModifierContext CreateScoringContext(int baseScore, int[] dice, YachtCategory category) + { + return new ModifierContext + { + BaseScore = baseScore, + DiceValues = dice, + Category = category, + AllActiveModifiers = registry.Active, + }; + } + + // ── Phase Ordering ────────────────────────────────────────── + + [Test] + public void Execute_AdditiveBeforeMultiplicative() + { + var addEffect = AddFlatScoreEffect.CreateForTest(10, ModifierPhase.Additive); + var mulEffect = MultiplyScoreEffect.CreateForTest(2f, ModifierPhase.Multiplicative); + + var addDef = CreateDef("add", TriggerType.OnCategoryScored, null, + new List { addEffect }); + var mulDef = CreateDef("mul", TriggerType.OnCategoryScored, null, + new List { mulEffect }); + + RegisterAndActivate(mulDef); // registered first, but multiplicative phase + RegisterAndActivate(addDef); // registered second, but additive phase + + var ctx = CreateScoringContext(20, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); // (20 + 10) * 2 = 60 Assert.AreEqual(60, result.FinalScore); } [Test] - public void Apply_CategoryLevelBeforeFinalScore() + public void Execute_PostMultiplicativeAfterMultiplicative() { - var perDie = ModifierData.CreateForTest("perDie", ModifierScope.SelectedCategory, - ModifierEffectType.AddPerDieValue, 5f, dieValue: 1); - var flat = ModifierData.CreateForTest("flat", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 100f); + var mulEffect = MultiplyScoreEffect.CreateForTest(2f, ModifierPhase.Multiplicative); + var postMulEffect = PostMultiplyEffect.CreateForTest(3f, ModifierPhase.PostMultiplicative); - var modifiers = new List { flat, perDie }; - var result = ScoreResult.Create(3, new[] { 1, 1, 1, 2, 3 }, YachtCategory.Ones); + var mulDef = CreateDef("mul", TriggerType.OnCategoryScored, null, + new List { mulEffect }); + var postDef = CreateDef("post", TriggerType.OnCategoryScored, null, + new List { postMulEffect }); - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + RegisterAndActivate(postDef); + RegisterAndActivate(mulDef); - // FlatBonus = 15 (perDie: 5*3) + 100 (flat) = 115 - // FinalScore = (3 + 115) * 1 = 118 - Assert.AreEqual(118, result.FinalScore); + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + // (10 + 0) * 2 * 3 = 60 + Assert.AreEqual(60, result.FinalScore); } + // ── Condition Filtering ───────────────────────────────────── + [Test] - public void Apply_ScopeFiltering_SkipsWrongScope() + public void Execute_ConditionFails_SkipsEffect() { - var mod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, - ModifierEffectType.AddFlatToFinalScore, 50f); + var condition = CategoryCondition.CreateForTest(YachtCategory.FullHouse); + var effect = AddFlatScoreEffect.CreateForTest(100); - var modifiers = new List { mod }; - var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored, + new List { condition }, + new List { effect }); - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + RegisterAndActivate(def); + + // Scoring Ones, not FullHouse — condition should fail + var ctx = CreateScoringContext(5, new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); Assert.AreEqual(0, result.FlatBonus); - Assert.AreEqual(10, result.FinalScore); + Assert.AreEqual(5, result.FinalScore); } [Test] - public void Apply_CategoryFilter_SkipsWrongCategory() + public void Execute_ConditionPasses_AppliesEffect() { - var mod = ModifierData.CreateForTest("fh", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 15f, - targetCategory: YachtCategory.FullHouse, hasCategoryFilter: true); + var condition = CategoryCondition.CreateForTest(YachtCategory.FullHouse); + var effect = AddFlatScoreEffect.CreateForTest(15); - var modifiers = new List { mod }; - var result = ScoreResult.Create(5, new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored, + new List { condition }, + new List { effect }); - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + RegisterAndActivate(def); - Assert.AreEqual(0, result.FlatBonus); - } - - [Test] - public void Apply_CategoryFilter_AppliesMatchingCategory() - { - var mod = ModifierData.CreateForTest("fh", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 15f, - targetCategory: YachtCategory.FullHouse, hasCategoryFilter: true); - - var modifiers = new List { mod }; - var result = ScoreResult.Create(25, new[] { 3, 3, 3, 2, 2 }, YachtCategory.FullHouse); - - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + var ctx = CreateScoringContext(25, new[] { 3, 3, 3, 2, 2 }, YachtCategory.FullHouse); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); Assert.AreEqual(15, result.FlatBonus); Assert.AreEqual(40, result.FinalScore); } + // ── Trigger Filtering ─────────────────────────────────────── + [Test] - public void Apply_MultipleModifiers_CorrectOrder() + public void Execute_WrongTrigger_SkipsModifier() { - var perDieAdd = ModifierData.CreateForTest("pda", ModifierScope.SelectedCategory, - ModifierEffectType.AddPerDieValue, 2f, dieValue: 3); - var perDieMul = ModifierData.CreateForTest("pdm", ModifierScope.SelectedCategory, - ModifierEffectType.MultiplyPerDieValue, 1.5f, dieValue: 3); - var flatAdd = ModifierData.CreateForTest("fa", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f); - var finalMul = ModifierData.CreateForTest("fm", ModifierScope.SelectedCategory, - ModifierEffectType.MultiplyFinalScore, 2f); + var effect = AddFlatScoreEffect.CreateForTest(999); + var def = CreateDef("turn-bonus", TriggerType.OnTurnStart, null, + new List { effect }); + + RegisterAndActivate(def); + + // Fire OnCategoryScored, not OnTurnStart + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(10, result.FinalScore); + } + + // ── Multiple Modifiers ────────────────────────────────────── + + [Test] + public void Execute_MultipleModifiers_CorrectOrder() + { + var perDieAdd = AddPerDieEffect.CreateForTest(2, targetDieValue: 3, phase: ModifierPhase.Additive); + var perDieMul = MultiplyPerDieEffect.CreateForTest(1.5f, targetDieValue: 3, phase: ModifierPhase.Multiplicative); + var flatAdd = AddFlatScoreEffect.CreateForTest(10, ModifierPhase.Additive, priority: 10); + var finalMul = MultiplyScoreEffect.CreateForTest(2f, ModifierPhase.Multiplicative, priority: 10); + + var def1 = CreateDef("pda", TriggerType.OnCategoryScored, null, + new List { perDieAdd }); + var def2 = CreateDef("pdm", TriggerType.OnCategoryScored, null, + new List { perDieMul }); + var def3 = CreateDef("fa", TriggerType.OnCategoryScored, null, + new List { flatAdd }); + var def4 = CreateDef("fm", TriggerType.OnCategoryScored, null, + new List { finalMul }); + + RegisterAndActivate(def4); + RegisterAndActivate(def3); + RegisterAndActivate(def2); + RegisterAndActivate(def1); - var modifiers = new List { finalMul, flatAdd, perDieMul, perDieAdd }; // dice: [3, 3, 3, 1, 2] — 3 threes - var result = ScoreResult.Create(9, new[] { 3, 3, 3, 1, 2 }, YachtCategory.Threes); + var ctx = CreateScoringContext(9, new[] { 3, 3, 3, 1, 2 }, YachtCategory.Threes); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); - - // Pass 1 (cat additive): perDieAdd: +2*3 = +6 FlatBonus - // Pass 2 (cat multiplicative): perDieMul: 1.5^3 = 3.375 Multiplier - // Pass 3 (final additive): flatAdd: +10 FlatBonus → total FlatBonus = 16 - // Pass 4 (final multiplicative): finalMul: 3.375 * 2 = 6.75 Multiplier + // Additive phase: perDieAdd (+2*3=6) + flatAdd (+10) → FlatBonus = 16 + // Multiplicative phase: perDieMul (1.5^3=3.375) then finalMul (*2) → Multiplier = 6.75 // FinalScore = floor((9 + 16) * 6.75) = floor(168.75) = 168 - Assert.AreEqual(6, result.FlatBonus + 10); // just check pipeline ran; full calc below + Assert.AreEqual(16, result.FlatBonus); Assert.AreEqual(168, result.FinalScore); } - [Test] - public void Apply_NullModifiers_DoesNotThrow() - { - var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + // ── Empty / Null Cases ────────────────────────────────────── - Assert.DoesNotThrow(() => - ModifierPipeline.Apply(null, ref result, ModifierScope.SelectedCategory)); + [Test] + public void Execute_NoActiveModifiers_NoChange() + { + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); Assert.AreEqual(10, result.FinalScore); + Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(1f, result.Multiplier); } [Test] - public void Apply_EmptyList_NoChange() + public void Execute_InactiveModifier_Skipped() { - var modifiers = new List(); - var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var effect = AddFlatScoreEffect.CreateForTest(50); + var def = CreateDef("inactive", TriggerType.OnCategoryScored, null, + new List { effect }); - ModifierPipeline.Apply(modifiers, ref result, ModifierScope.SelectedCategory); + // Add but don't activate + registry.Add(def); + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.AreEqual(0, result.FlatBonus); Assert.AreEqual(10, result.FinalScore); } + + // ── Side Effects ──────────────────────────────────────────── + + [Test] + public void Execute_SideEffectsInSideEffectPhase() + { + var scoreEffect = AddFlatScoreEffect.CreateForTest(10, ModifierPhase.Additive); + var currencyEffect = AddCurrencyEffect.CreateForTest(25, ModifierPhase.SideEffect); + + var def = CreateDef("rewards", TriggerType.OnCategoryScored, null, + new List { scoreEffect, currencyEffect }); + + RegisterAndActivate(def); + + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.AreEqual(10, result.FlatBonus); + Assert.AreEqual(25, result.CurrencyDelta); + Assert.AreEqual(20, result.FinalScore); + } + + // ── Tracing ───────────────────────────────────────────────── + + [Test] + public void Execute_TracingEnabled_PopulatesDebugLog() + { + pipeline.TracingEnabled = true; + + var effect = AddFlatScoreEffect.CreateForTest(10); + var def = CreateDef("traced", TriggerType.OnCategoryScored, null, + new List { effect }); + + RegisterAndActivate(def); + + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.IsNotNull(result.DebugLog); + Assert.IsTrue(result.DebugLog.Count > 0); + } + + // ── DieValue Condition ────────────────────────────────────── + + [Test] + public void Execute_DieValueCondition_OnlyTriggersOnMatch() + { + var condition = DieValueCondition.CreateForTest(6, minCount: 3); + var effect = AddFlatScoreEffect.CreateForTest(100); + + var def = CreateDef("sixes-bonus", TriggerType.OnCategoryScored, + new List { condition }, + new List { effect }); + + RegisterAndActivate(def); + + // Only 2 sixes — condition requires 3 + var ctx = CreateScoringContext(12, new[] { 6, 6, 1, 2, 3 }, YachtCategory.Sixes); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.AreEqual(0, result.FlatBonus); + + // 3 sixes — condition passes + var ctx2 = CreateScoringContext(18, new[] { 6, 6, 6, 1, 2 }, YachtCategory.Sixes); + var result2 = pipeline.Execute(TriggerType.OnCategoryScored, ctx2).GetAwaiter().GetResult(); + + Assert.AreEqual(100, result2.FlatBonus); + } + + // ── MinScore Condition ────────────────────────────────────── + + [Test] + public void Execute_MinScoreCondition_ThresholdWorks() + { + var condition = MinScoreCondition.CreateForTest(20); + var effect = MultiplyScoreEffect.CreateForTest(2f); + + var def = CreateDef("high-score-bonus", TriggerType.OnCategoryScored, + new List { condition }, + new List { effect }); + + RegisterAndActivate(def); + + // Below threshold + var ctx = CreateScoringContext(15, new[] { 3, 3, 3, 3, 3 }, YachtCategory.Threes); + var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult(); + + Assert.AreEqual(1f, result.Multiplier); + + // At threshold + var ctx2 = CreateScoringContext(20, new[] { 4, 4, 4, 4, 4 }, YachtCategory.Fours); + var result2 = pipeline.Execute(TriggerType.OnCategoryScored, ctx2).GetAwaiter().GetResult(); + + Assert.AreEqual(2f, result2.Multiplier); + } + + // ── ToScoreResult ─────────────────────────────────────────── + + [Test] + public void ToScoreResult_ConvertsCorrectly() + { + var ctx = CreateScoringContext(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); + ctx.FlatBonus = 5; + ctx.Multiplier = 2f; + ctx.PostMultiplier = 1.5f; + + ScoreResult sr = ctx.ToScoreResult(); + + Assert.AreEqual(10, sr.BaseScore); + Assert.AreEqual(5, sr.FlatBonus); + Assert.AreEqual(3f, sr.Multiplier, 0.001f); // 2 * 1.5 + Assert.AreEqual(YachtCategory.Chance, sr.Category); + } } } diff --git a/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs b/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs index e5e359e..661716a 100644 --- a/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs +++ b/Assets/Scripts/Tests/Editor/ScoringSystemTests.cs @@ -1,8 +1,6 @@ -using System.Collections.Generic; using NUnit.Framework; using UnityEngine; using YachtDice.Scoring; -using YachtDice.Modifiers; namespace YachtDice.Tests { @@ -22,41 +20,15 @@ namespace YachtDice.Tests } [Test] - public void PreviewScore_AppliesOnlySelectedCategoryModifiers() + public void ScoreCategory_WithNoModifiers_CalculatesBaseOnly() { var system = CreateScoringSystem(); + var result = system.ScoreCategory(new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); - var selectedMod = ModifierData.CreateForTest("sel", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f); - var anyCloseMod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, - ModifierEffectType.AddFlatToFinalScore, 100f); - - system.SetActiveModifiers(new List { selectedMod, anyCloseMod }); - - var result = system.PreviewScore(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); - - // Only SelectedCategory mod should apply in preview - Assert.AreEqual(10, result.FlatBonus); - Assert.AreEqual(25, result.FinalScore); // (15 + 10) * 1 - } - - [Test] - public void ScoreCategory_AppliesBothScopes() - { - var system = CreateScoringSystem(); - - var selectedMod = ModifierData.CreateForTest("sel", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f); - var anyCloseMod = ModifierData.CreateForTest("any", ModifierScope.AnyCategoryClosed, - ModifierEffectType.AddFlatToFinalScore, 20f); - - system.SetActiveModifiers(new List { selectedMod, anyCloseMod }); - - var result = system.ScoreCategory(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); - - // Both scopes should apply - Assert.AreEqual(30, result.FlatBonus); - Assert.AreEqual(45, result.FinalScore); // (15 + 30) * 1 + Assert.AreEqual(50, result.BaseScore); + Assert.AreEqual(0, result.FlatBonus); + Assert.AreEqual(1f, result.Multiplier); + Assert.AreEqual(50, result.FinalScore); } [Test] @@ -89,15 +61,36 @@ namespace YachtDice.Tests } [Test] - public void ScoreCategory_WithNoModifiers_CalculatesBaseOnly() + public void PreviewScore_WithNoModifiers_CalculatesBaseOnly() { var system = CreateScoringSystem(); - var result = system.ScoreCategory(new[] { 6, 6, 6, 6, 6 }, YachtCategory.Yacht); + var result = system.PreviewScore(new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance); - Assert.AreEqual(50, result.BaseScore); + Assert.AreEqual(15, result.BaseScore); Assert.AreEqual(0, result.FlatBonus); - Assert.AreEqual(1f, result.Multiplier); - Assert.AreEqual(50, result.FinalScore); + Assert.AreEqual(15, result.FinalScore); + } + + [Test] + public void TotalScore_SumsAllScoredCategories() + { + var system = CreateScoringSystem(); + system.ScoreCategory(new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + system.ScoreCategory(new[] { 2, 2, 2, 2, 2 }, YachtCategory.Twos); + + Assert.AreEqual(15, system.TotalScore); // 5 + 10 + } + + [Test] + public void ResetScorecard_ClearsAll() + { + var system = CreateScoringSystem(); + system.ScoreCategory(new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones); + + system.ResetScorecard(); + + Assert.AreEqual(0, system.TotalScore); + Assert.IsFalse(system.IsCategoryUsed(YachtCategory.Ones)); } } } diff --git a/Assets/Scripts/Tests/Editor/ShopModelTests.cs b/Assets/Scripts/Tests/Editor/ShopModelTests.cs index deeb7e7..973e4ea 100644 --- a/Assets/Scripts/Tests/Editor/ShopModelTests.cs +++ b/Assets/Scripts/Tests/Editor/ShopModelTests.cs @@ -3,13 +3,15 @@ using UnityEngine; using YachtDice.Economy; using YachtDice.Inventory; using YachtDice.Shop; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; namespace YachtDice.Tests { public sealed class ShopModelTests { private CurrencyBank bank; + private ModifierRegistry registry; private InventoryModel inventory; private ShopModel shop; @@ -20,7 +22,8 @@ namespace YachtDice.Tests bank = go.AddComponent(); bank.SetBalance(500); - inventory = new InventoryModel(5); + registry = new ModifierRegistry(5); + inventory = new InventoryModel(registry); shop = new ShopModel(bank, inventory); } @@ -31,11 +34,19 @@ namespace YachtDice.Tests Object.DestroyImmediate(go.gameObject); } + private ModifierDefinitionSO CreateDef(string id = "test", + bool hasLimitedUses = false, int maxUses = 0, + int shopPrice = 100, int sellPrice = 50) + { + return ModifierDefinitionSO.CreateForTest(id, null, + hasLimitedUses: hasLimitedUses, maxUses: maxUses, + shopPrice: shopPrice, sellPrice: sellPrice); + } + [Test] public void TryPurchase_SucceedsWithSufficientCurrency() { - var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + var mod = CreateDef("test", shopPrice: 100); bool result = shop.TryPurchase(mod); @@ -48,8 +59,7 @@ namespace YachtDice.Tests public void TryPurchase_FailsWhenBroke() { bank.SetBalance(10); - var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + var mod = CreateDef("test", shopPrice: 100); bool result = shop.TryPurchase(mod); @@ -61,9 +71,7 @@ namespace YachtDice.Tests [Test] public void TryPurchase_PermanentCannotBeBoughtTwice() { - var mod = ModifierData.CreateForTest("perm", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, - durability: ModifierDurability.Permanent, shopPrice: 100); + var mod = CreateDef("perm", shopPrice: 100); shop.TryPurchase(mod); bool secondResult = shop.TryPurchase(mod); @@ -76,9 +84,7 @@ namespace YachtDice.Tests [Test] public void TryPurchase_LimitedCanBeReBought() { - var mod = ModifierData.CreateForTest("limited", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, - durability: ModifierDurability.LimitedUses, maxUses: 3, shopPrice: 100); + var mod = CreateDef("limited", hasLimitedUses: true, maxUses: 3, shopPrice: 100); shop.TryPurchase(mod); bool secondResult = shop.TryPurchase(mod); @@ -91,11 +97,10 @@ namespace YachtDice.Tests [Test] public void TryPurchase_FiresPurchaseEvent() { - ModifierData purchased = null; - shop.OnItemPurchased += data => purchased = data; + ModifierDefinitionSO purchased = null; + shop.OnItemPurchased += def => purchased = def; - var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + var mod = CreateDef("test", shopPrice: 100); shop.TryPurchase(mod); @@ -106,8 +111,7 @@ namespace YachtDice.Tests [Test] public void GetItemState_Available_WhenCanAfford() { - var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + var mod = CreateDef("test", shopPrice: 100); Assert.AreEqual(ShopItemState.Available, shop.GetItemState(mod)); } @@ -116,8 +120,7 @@ namespace YachtDice.Tests public void GetItemState_TooExpensive_WhenCannotAfford() { bank.SetBalance(10); - var mod = ModifierData.CreateForTest("test", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, shopPrice: 100); + var mod = CreateDef("test", shopPrice: 100); Assert.AreEqual(ShopItemState.TooExpensive, shop.GetItemState(mod)); } @@ -125,9 +128,7 @@ namespace YachtDice.Tests [Test] public void GetItemState_Owned_WhenPermanentPurchased() { - var mod = ModifierData.CreateForTest("perm", ModifierScope.SelectedCategory, - ModifierEffectType.AddFlatToFinalScore, 10f, - durability: ModifierDurability.Permanent, shopPrice: 100); + var mod = CreateDef("perm", shopPrice: 100); shop.TryPurchase(mod); diff --git a/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef b/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef index c1a6c5d..d8f2ec9 100644 --- a/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef +++ b/Assets/Scripts/Tests/Editor/YachtDice.Tests.Editor.asmdef @@ -4,7 +4,8 @@ "references": [ "UnityEngine.TestRunner", "UnityEditor.TestRunner", - "YachtDice.Runtime" + "YachtDice.Runtime", + "UniTask" ], "includePlatforms": [ "Editor" diff --git a/Assets/Scripts/UI/GameController.cs b/Assets/Scripts/UI/GameController.cs index fe040ec..1911c37 100644 --- a/Assets/Scripts/UI/GameController.cs +++ b/Assets/Scripts/UI/GameController.cs @@ -1,13 +1,15 @@ using System; using System.Collections.Generic; using UnityEngine; +using VContainer; using YachtDice.Game; using YachtDice.Scoring; using YachtDice.Economy; using YachtDice.Shop; using YachtDice.Inventory; using YachtDice.Persistence; -using YachtDice.Modifiers; +using YachtDice.Modifiers.Definition; +using YachtDice.Modifiers.Runtime; namespace YachtDice.UI { @@ -43,9 +45,16 @@ namespace YachtDice.UI private int totalCategoryCount; + private ModifierRegistry modifierRegistry; private InventoryModel inventoryModel; private ShopModel shopModel; + [Inject] + public void Construct(ModifierRegistry modifierRegistry) + { + this.modifierRegistry = modifierRegistry; + } + // ── Lifecycle ────────────────────────────────────────────── private void Awake() @@ -70,7 +79,10 @@ namespace YachtDice.UI // Currency if (currencyBank != null) currencyBank.OnBalanceChanged += HandleCurrencyChanged; + } + private void Start() + { InitializeModifierSystems(); } @@ -92,21 +104,26 @@ namespace YachtDice.UI if (currencyBank != null) currencyBank.OnBalanceChanged -= HandleCurrencyChanged; - if (inventoryModel != null) - inventoryModel.OnInventoryChanged -= HandleInventoryChangedForSave; + if (modifierRegistry != null) + modifierRegistry.OnChanged -= HandleInventoryChangedForSave; } // ── Modifier System Init ───────────────────────────────── private void InitializeModifierSystems() { - inventoryModel = new InventoryModel(maxActiveModifierSlots); - inventoryModel.OnInventoryChanged += HandleInventoryChangedForSave; + if (modifierRegistry == null) + modifierRegistry = new ModifierRegistry(maxActiveModifierSlots); + else + modifierRegistry.SetMaxActiveSlots(maxActiveModifierSlots); - ShopCatalog catalog = shopController != null ? shopController.Catalog : null; + modifierRegistry.OnChanged += HandleInventoryChangedForSave; + + inventoryModel = new InventoryModel(modifierRegistry); shopModel = new ShopModel(currencyBank, inventoryModel); + ModifierCatalogSO catalog = shopController != null ? shopController.Catalog : null; LoadSaveData(catalog); if (inventoryController != null) @@ -119,7 +136,7 @@ namespace YachtDice.UI gameInfoView.SetCurrencyText(currencyBank.Balance); } - private void LoadSaveData(ShopCatalog catalog) + private void LoadSaveData(ModifierCatalogSO catalog) { SaveData save = SaveSystem.Load(); @@ -128,35 +145,34 @@ namespace YachtDice.UI if (catalog != null && save.OwnedModifiers.Count > 0) { - var runtimeList = new List(); + var entries = new List(); var permanentIds = new HashSet(); for (int i = 0; i < save.OwnedModifiers.Count; i++) { - var entry = save.OwnedModifiers[i]; - ModifierData data = catalog.FindById(entry.ModifierId); + var oldEntry = save.OwnedModifiers[i]; + var def = catalog.FindById(oldEntry.ModifierId); - if (data == null) + if (def == null) { - Debug.LogWarning($"Modifier '{entry.ModifierId}' not found in catalog, skipping."); + Debug.LogWarning($"Modifier '{oldEntry.ModifierId}' not found in catalog, skipping."); continue; } - var runtime = new ModifierRuntime + entries.Add(new ModifierSaveEntry { - ModifierId = entry.ModifierId, - IsActive = entry.IsActive, - RemainingUses = entry.RemainingUses, - Data = data - }; + ModifierId = oldEntry.ModifierId, + IsActive = oldEntry.IsActive, + RemainingUses = oldEntry.RemainingUses, + Stacks = oldEntry.Stacks, + CustomState = oldEntry.CustomState, + }); - runtimeList.Add(runtime); - - if (data.Durability == ModifierDurability.Permanent) - permanentIds.Add(data.Id); + if (!def.HasLimitedUses) + permanentIds.Add(def.Id); } - inventoryModel.LoadState(runtimeList); + modifierRegistry.LoadSaveData(entries, catalog); shopModel.LoadPurchasedPermanentIds(permanentIds); } } @@ -168,15 +184,10 @@ namespace YachtDice.UI Currency = currencyBank != null ? currencyBank.Balance : 0 }; - var owned = inventoryModel.GetAllForSave(); - for (int i = 0; i < owned.Count; i++) + var entries = modifierRegistry.GetSaveData(); + for (int i = 0; i < entries.Count; i++) { - save.OwnedModifiers.Add(new ModifierSaveEntry - { - ModifierId = owned[i].ModifierId, - IsActive = owned[i].IsActive, - RemainingUses = owned[i].RemainingUses - }); + save.OwnedModifiers.Add(entries[i]); } SaveSystem.Save(save); diff --git a/Assets/Scripts/YachtDice.Runtime.asmdef b/Assets/Scripts/YachtDice.Runtime.asmdef index ef8dad3..13129bb 100644 --- a/Assets/Scripts/YachtDice.Runtime.asmdef +++ b/Assets/Scripts/YachtDice.Runtime.asmdef @@ -4,7 +4,9 @@ "references": [ "Unity.TextMeshPro", "Unity.InputSystem", - "Newtonsoft.Json" + "Newtonsoft.Json", + "VContainer", + "UniTask" ], "includePlatforms": [], "excludePlatforms": [],