[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
@@ -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);
}
}
}