68c4abace3
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>
337 lines
14 KiB
C#
337 lines
14 KiB
C#
using System.Collections.Generic;
|
|
using NUnit.Framework;
|
|
using UnityEngine;
|
|
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
|
|
{
|
|
private ModifierRegistry registry;
|
|
private ModifierPipeline pipeline;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
registry = new ModifierRegistry(10);
|
|
pipeline = new ModifierPipeline(registry);
|
|
pipeline.TracingEnabled = false; // disable debug logs during tests
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
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 Execute_PostMultiplicativeAfterMultiplicative()
|
|
{
|
|
var mulEffect = MultiplyScoreEffect.CreateForTest(2f, ModifierPhase.Multiplicative);
|
|
var postMulEffect = PostMultiplyEffect.CreateForTest(3f, ModifierPhase.PostMultiplicative);
|
|
|
|
var mulDef = CreateDef("mul", TriggerType.OnCategoryScored, null,
|
|
new List<EffectSO> { mulEffect });
|
|
var postDef = CreateDef("post", TriggerType.OnCategoryScored, null,
|
|
new List<EffectSO> { postMulEffect });
|
|
|
|
RegisterAndActivate(postDef);
|
|
RegisterAndActivate(mulDef);
|
|
|
|
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 Execute_ConditionFails_SkipsEffect()
|
|
{
|
|
var condition = CategoryCondition.CreateForTest(YachtCategory.FullHouse);
|
|
var effect = AddFlatScoreEffect.CreateForTest(100);
|
|
|
|
var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored,
|
|
new List<ConditionSO> { condition },
|
|
new List<EffectSO> { effect });
|
|
|
|
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(5, result.FinalScore);
|
|
}
|
|
|
|
[Test]
|
|
public void Execute_ConditionPasses_AppliesEffect()
|
|
{
|
|
var condition = CategoryCondition.CreateForTest(YachtCategory.FullHouse);
|
|
var effect = AddFlatScoreEffect.CreateForTest(15);
|
|
|
|
var def = CreateDef("fh-bonus", TriggerType.OnCategoryScored,
|
|
new List<ConditionSO> { condition },
|
|
new List<EffectSO> { effect });
|
|
|
|
RegisterAndActivate(def);
|
|
|
|
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 Execute_WrongTrigger_SkipsModifier()
|
|
{
|
|
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);
|
|
|
|
// dice: [3, 3, 3, 1, 2] — 3 threes
|
|
var ctx = CreateScoringContext(9, new[] { 3, 3, 3, 1, 2 }, YachtCategory.Threes);
|
|
var result = pipeline.Execute(TriggerType.OnCategoryScored, ctx).GetAwaiter().GetResult();
|
|
|
|
// 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(16, result.FlatBonus);
|
|
Assert.AreEqual(168, result.FinalScore);
|
|
}
|
|
|
|
// ── Empty / Null Cases ──────────────────────────────────────
|
|
|
|
[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 Execute_InactiveModifier_Skipped()
|
|
{
|
|
var effect = AddFlatScoreEffect.CreateForTest(50);
|
|
var def = CreateDef("inactive", TriggerType.OnCategoryScored, null,
|
|
new List<EffectSO> { effect });
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|