[Refactor] Replace enum-driven modifier system with data-driven SO composition

Replace the entire static, enum-based modifier pipeline with a
composition-based, data-driven architecture using ScriptableObject
polymorphism. New modifiers can now be created by assembling SO building
blocks (Conditions + Effects + Behaviors) — no core code edits needed.

New architecture:
- Core/: TriggerType, ModifierPhase, ModifierContext, ICondition, IEffect
- Definition/: ModifierDefinitionSO, ModifierBehaviorSO, ConditionSO, EffectSO, ModifierCatalogSO
- Conditions/: DieValueCondition, CategoryCondition, MinScoreCondition, DiceCountCondition
- Effects/: AddFlat, AddPerDie, Multiply, MultiplyPerDie, PostMultiply, AddCurrency, ConsumeCharge
- Runtime/: ModifierInstance, ModifierRegistry (non-static service)
- Pipeline/: async ModifierPipeline with phase ordering, tracing, anti-recursion
- Editor/: ModifierDefinitionValidator with menu items
- Events/: GameEventBus (non-static typed dispatcher)
- DI/: GameLifetimeScope (VContainer composition root)

Deleted old system: ModifierData, ModifierEffect, ModifierEnums,
ModifierPipeline (static), ModifierRuntime, ModifierTarget, ShopCatalog,
ModifierAssetCreator.

Updated: ScoringSystem (VContainer + async), InventoryModel (delegates to
ModifierRegistry), ShopModel (uses ModifierDefinitionSO), GameController
(VContainer injection), SaveData (uses Runtime.ModifierSaveEntry), all
views/controllers, and all test files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 06:20:23 +07:00
parent 6e19de2f3d
commit 68c4abace3
57 changed files with 2227 additions and 912 deletions
-130
View File
@@ -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<ShopCatalog>();
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<ModifierData>();
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
+41
View File
@@ -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<ModifierRegistry>(Lifetime.Singleton);
builder.Register<ModifierPipeline>(Lifetime.Singleton);
builder.Register<GameEventBus>(Lifetime.Singleton);
// Scene MonoBehaviour components
builder.RegisterComponent(scoringSystem);
builder.RegisterComponent(currencyBank);
builder.RegisterComponent(gameManager);
builder.RegisterComponent(diceManager);
}
}
}
+21
View File
@@ -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<ModifierContext> Fire(TriggerType trigger, ModifierContext context)
{
return pipeline.Execute(trigger, context);
}
}
}
@@ -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<ModifierData> activeModifiers)
{
if (scoringSystem != null)
scoringSystem.SetActiveModifiers(activeModifiers);
}
private void HandleInventoryChanged()
{
RefreshView();
+30 -113
View File
@@ -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<ModifierRuntime> ownedModifiers = new();
private int maxActiveSlots;
public IReadOnlyList<ModifierRuntime> OwnedModifiers => ownedModifiers;
public int MaxActiveSlots => maxActiveSlots;
private readonly ModifierRegistry registry;
public event Action OnInventoryChanged;
public event Action<List<ModifierData>> OnActiveModifiersChanged;
public event Action<IReadOnlyList<ModifierInstance>> 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<ModifierInstance> 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<ModifierDefinitionSO> 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<ModifierData> GetActiveModifierData()
{
var result = new List<ModifierData>();
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<ModifierDefinitionSO>();
var active = registry.Active;
for (int i = 0; i < active.Count; i++)
result.Add(active[i].Definition);
return result;
}
public void LoadState(List<ModifierRuntime> loaded)
{
ownedModifiers.Clear();
if (loaded != null)
ownedModifiers.AddRange(loaded);
OnActiveModifiersChanged?.Invoke(GetActiveModifierData());
OnInventoryChanged?.Invoke();
}
public List<ModifierRuntime> GetAllForSave() => new(ownedModifiers);
}
}
+19 -19
View File
@@ -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<ModifierRuntime> OnActivateClicked;
public event Action<ModifierRuntime> OnDeactivateClicked;
public event Action<ModifierRuntime> OnSellClicked;
public event Action<ModifierInstance> OnActivateClicked;
public event Action<ModifierInstance> OnDeactivateClicked;
public event Action<ModifierInstance> 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)
{
+11 -11
View File
@@ -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<InventorySlotView> spawnedSlots = new();
public event Action<ModifierRuntime> OnActivateClicked;
public event Action<ModifierRuntime> OnDeactivateClicked;
public event Action<ModifierRuntime> OnSellClicked;
public event Action<ModifierInstance> OnActivateClicked;
public event Action<ModifierInstance> OnDeactivateClicked;
public event Action<ModifierInstance> 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<ModifierRuntime> owned, int maxSlots)
public void Refresh(IReadOnlyList<ModifierInstance> 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);
}
}
@@ -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<CategoryCondition>();
so.requiredCategory = category;
return so;
}
#endif
}
}
@@ -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<DiceCountCondition>();
so.targetValue = targetValue;
so.minCount = minCount;
return so;
}
#endif
}
}
@@ -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<DieValueCondition>();
so.targetValue = targetValue;
so.minCount = minCount;
return so;
}
#endif
}
}
@@ -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<MinScoreCondition>();
so.minimumBaseScore = minScore;
return so;
}
#endif
}
}
@@ -0,0 +1,9 @@
using YachtDice.Modifiers.Runtime;
namespace YachtDice.Modifiers.Core
{
public interface ICondition
{
bool Evaluate(ModifierContext context, ModifierInstance instance);
}
}
+12
View File
@@ -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);
}
}
@@ -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<ModifierInstance> AllActiveModifiers;
// Trigger info
public TriggerType Trigger;
// Side-effect accumulators
public int CurrencyDelta;
// Debug trace (populated when tracing is enabled)
public List<string> 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<ModifierInstance> activeModifiers)
{
return new ModifierContext
{
BaseScore = baseScore,
DiceValues = diceValues,
Category = category,
CurrentRoll = currentRoll,
CurrentTurn = currentTurn,
PlayerCurrency = playerCurrency,
AllActiveModifiers = activeModifiers,
};
}
}
}
@@ -0,0 +1,12 @@
namespace YachtDice.Modifiers.Core
{
public enum ModifierPhase
{
Pre = 0,
Additive = 100,
Multiplicative = 200,
PostMultiplicative = 300,
Final = 400,
SideEffect = 500,
}
}
@@ -0,0 +1,10 @@
namespace YachtDice.Modifiers.Core
{
public enum ModifierRarity
{
Common,
Uncommon,
Rare,
Epic,
}
}
@@ -0,0 +1,18 @@
namespace YachtDice.Modifiers.Core
{
public enum TriggerType
{
OnCategoryScored,
OnTurnStart,
OnTurnEnd,
OnRollComplete,
OnDiceLocked,
OnShopOpened,
OnItemPurchased,
OnGameStart,
OnGameEnd,
OnModifierActivated,
OnModifierDeactivated,
OnCurrencyChanged,
}
}
@@ -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);
}
}
@@ -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
}
}
@@ -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<ConditionSO> conditions = new();
[SerializeField] private List<EffectSO> effects = new();
public TriggerType Trigger => trigger;
public IReadOnlyList<ConditionSO> Conditions => conditions;
public IReadOnlyList<EffectSO> 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<ConditionSO> conditions,
List<EffectSO> effects)
{
var so = CreateInstance<ModifierBehaviorSO>();
so.trigger = trigger;
so.conditions = conditions ?? new List<ConditionSO>();
so.effects = effects ?? new List<EffectSO>();
return so;
}
#endif
}
}
@@ -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<ModifierDefinitionSO> modifiers = new();
public IReadOnlyList<ModifierDefinitionSO> 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;
}
}
}
@@ -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<ModifierBehaviorSO> 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<ModifierBehaviorSO> Behaviors => behaviors;
#if UNITY_EDITOR
public static ModifierDefinitionSO CreateForTest(
string id,
List<ModifierBehaviorSO> behaviors,
bool hasLimitedUses = false,
int maxUses = 0,
int shopPrice = 100,
int sellPrice = 50,
ModifierRarity rarity = ModifierRarity.Common)
{
var so = CreateInstance<ModifierDefinitionSO>();
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<ModifierBehaviorSO>();
return so;
}
#endif
}
}
@@ -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<string, string>(); // id -> asset path
for (int i = 0; i < guids.Length; i++)
{
string path = AssetDatabase.GUIDToAssetPath(guids[i]);
var def = AssetDatabase.LoadAssetAtPath<ModifierDefinitionSO>(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<ModifierCatalogSO>(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<string>();
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
@@ -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<AddCurrencyEffect>();
so.amount = amount;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<AddFlatScoreEffect>();
so.value = value;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<AddPerDieEffect>();
so.valuePerDie = valuePerDie;
so.targetDieValue = targetDieValue;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<ConsumeChargeEffect>();
so.charges = charges;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<MultiplyPerDieEffect>();
so.multiplierPerDie = multiplierPerDie;
so.targetDieValue = targetDieValue;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<MultiplyScoreEffect>();
so.multiplier = multiplier;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
@@ -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<PostMultiplyEffect>();
so.postMultiplier = postMultiplier;
so.SetPhaseForTest(phase);
so.SetPriorityForTest(priority);
return so;
}
#endif
}
}
-92
View File
@@ -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<ModifierData>();
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
}
}
@@ -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<ModifierEffectType, ModifierHandler> 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;
}
}
}
-30
View File
@@ -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
}
}
@@ -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<ModifierData> 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;
}
}
}
@@ -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
};
}
}
}
@@ -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;
}
}
@@ -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<EffectEntry>
{
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);
}
}
}
@@ -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<EffectEntry> 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<ModifierContext> 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<string>();
}
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;
}
}
}
@@ -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<TraceEntry> 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();
}
}
}
@@ -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<string, float> 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;
}
}
}
@@ -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<ModifierInstance> instances = new();
private readonly List<ModifierInstance> activeCache = new();
private int maxActiveSlots;
private bool activeCacheDirty = true;
public event Action OnChanged;
public event Action<IReadOnlyList<ModifierInstance>> OnActiveModifiersChanged;
public IReadOnlyList<ModifierInstance> All => instances;
public int MaxActiveSlots => maxActiveSlots;
public ModifierRegistry(int maxActiveSlots = 5)
{
this.maxActiveSlots = maxActiveSlots;
}
public IReadOnlyList<ModifierInstance> 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<ModifierSaveEntry> GetSaveData()
{
var entries = new List<ModifierSaveEntry>();
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<ModifierSaveEntry> 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;
}
}
}
@@ -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<CustomStateEntry> CustomState = new();
}
[Serializable]
public class CustomStateEntry
{
public string Key;
public float Value;
}
}
+2 -9
View File
@@ -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<ModifierSaveEntry> OwnedModifiers = new();
}
[Serializable]
public sealed class ModifierSaveEntry
{
public string ModifierId;
public bool IsActive;
public int RemainingUses;
}
}
+84 -15
View File
@@ -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<YachtCategory, int> scorecard = new();
private readonly HashSet<YachtCategory> usedCategories = new();
private List<ModifierData> 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<ModifierData> modifiers)
{
activeModifierData = modifiers ?? new List<ModifierData>();
}
public IReadOnlyList<ModifierData> 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<ScoreResult> 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;
-24
View File
@@ -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<ModifierData> availableModifiers = new();
public IReadOnlyList<ModifierData> 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;
}
}
}
+9 -9
View File
@@ -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);
}
}
}
+6 -5
View File
@@ -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<ModifierData> OnBuyClicked;
public event Action<ModifierDefinitionSO> 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;
+10 -12
View File
@@ -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<string> purchasedPermanentIds = new();
public event Action<ModifierData> OnItemPurchased;
public event Action<ModifierDefinitionSO> 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,
}
}
+9 -9
View File
@@ -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<ShopItemView> spawnedItems = new();
public event Action<ModifierData> OnBuyClicked;
public event Action<ModifierDefinitionSO> 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<ModifierData> catalog, ShopModel model)
public void Populate(IReadOnlyList<ModifierDefinitionSO> 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<ModifierData> catalog, ShopModel model)
public void RefreshStates(IReadOnlyList<ModifierDefinitionSO> 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);
}
}
@@ -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]);
@@ -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);
}
}
}
@@ -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<ModifierData> { mulMod, addMod };
var result = ScoreResult.Create(20, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance);
private ModifierDefinitionSO CreateDef(string id,
TriggerType trigger,
List<ConditionSO> conditions,
List<EffectSO> effects)
{
var behavior = ModifierBehaviorSO.CreateForTest(trigger, conditions, effects);
return ModifierDefinitionSO.CreateForTest(id,
new List<ModifierBehaviorSO> { 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<EffectSO> { addEffect });
var mulDef = CreateDef("mul", TriggerType.OnCategoryScored, null,
new List<EffectSO> { 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<ModifierData> { flat, perDie };
var result = ScoreResult.Create(3, new[] { 1, 1, 1, 2, 3 }, YachtCategory.Ones);
var mulDef = CreateDef("mul", TriggerType.OnCategoryScored, null,
new List<EffectSO> { mulEffect });
var postDef = CreateDef("post", TriggerType.OnCategoryScored, null,
new List<EffectSO> { 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<ModifierData> { mod };
var result = ScoreResult.Create(10, new[] { 1, 2, 3, 4, 5 }, YachtCategory.Chance);
var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored,
new List<ConditionSO> { condition },
new List<EffectSO> { 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<ModifierData> { mod };
var result = ScoreResult.Create(5, new[] { 1, 1, 1, 1, 1 }, YachtCategory.Ones);
var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored,
new List<ConditionSO> { condition },
new List<EffectSO> { 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<ModifierData> { 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<EffectSO> { 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<EffectSO> { perDieAdd });
var def2 = CreateDef("pdm", TriggerType.OnCategoryScored, null,
new List<EffectSO> { perDieMul });
var def3 = CreateDef("fa", TriggerType.OnCategoryScored, null,
new List<EffectSO> { flatAdd });
var def4 = CreateDef("fm", TriggerType.OnCategoryScored, null,
new List<EffectSO> { finalMul });
RegisterAndActivate(def4);
RegisterAndActivate(def3);
RegisterAndActivate(def2);
RegisterAndActivate(def1);
var modifiers = new List<ModifierData> { 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<ModifierData>();
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<EffectSO> { 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<EffectSO> { 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<EffectSO> { 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<ConditionSO> { condition },
new List<EffectSO> { 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<ConditionSO> { condition },
new List<EffectSO> { 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);
}
}
}
@@ -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<ModifierData> { 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<ModifierData> { 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));
}
}
}
+24 -23
View File
@@ -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<CurrencyBank>();
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);
@@ -4,7 +4,8 @@
"references": [
"UnityEngine.TestRunner",
"UnityEditor.TestRunner",
"YachtDice.Runtime"
"YachtDice.Runtime",
"UniTask"
],
"includePlatforms": [
"Editor"
+42 -31
View File
@@ -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<ModifierRuntime>();
var entries = new List<ModifierSaveEntry>();
var permanentIds = new HashSet<string>();
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);
+3 -1
View File
@@ -4,7 +4,9 @@
"references": [
"Unity.TextMeshPro",
"Unity.InputSystem",
"Newtonsoft.Json"
"Newtonsoft.Json",
"VContainer",
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],