using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; using GameFramework.DataTable; using GeometryTD.CustomComponent; using GeometryTD.DataTable; using GeometryTD.Definition; using GeometryTD.Procedure; using Newtonsoft.Json.Linq; using NUnit.Framework; using UnityEngine; using Object = UnityEngine.Object; namespace GeometryTD.Tests.EditMode { public sealed class EventOptionExecutorTests { private GameObject _inventoryGenerationObject; private InventoryGenerationComponent _originalInventoryGeneration; [SetUp] public void SetUp() { TagDefinitionRegistry.ResetToDefaults(); TagGenerationRuleRegistry.ResetToDefaults(); RarityTagBudgetRuleRegistry.ResetToDefaults(); } [TearDown] public void TearDown() { TagDefinitionRegistry.ResetToDefaults(); TagGenerationRuleRegistry.ResetToDefaults(); RarityTagBudgetRuleRegistry.ResetToDefaults(); SetStaticInventoryGeneration(_originalInventoryGeneration); _originalInventoryGeneration = null; if (_inventoryGenerationObject != null) { Object.DestroyImmediate(_inventoryGenerationObject); _inventoryGenerationObject = null; } } [Test] public void EvaluateOption_Blocks_When_Gold_Requirement_Not_Met() { EventOptionExecutor executor = new EventOptionExecutor(); EventOption option = new EventOption( "下注 100", new EventRequirementBase[] { new GoldAtLeastRequirement(new GoldAtLeastParam(100)) }, Array.Empty(), Array.Empty()); EventOptionAvailability availability = executor.EvaluateOption( option, new BackpackInventoryData { Gold = 60 }); Assert.That(availability.IsSelectable, Is.False); Assert.That(availability.BlockedReason, Is.EqualTo("需要至少 100 金币")); } [Test] public void Execute_Applies_Cost_Before_Probability_And_Skips_Reward_On_Failure() { EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 1, "赌马", "测试事件", new[] { new EventOption( "下注", new EventRequirementBase[] { new GoldAtLeastRequirement(new GoldAtLeastParam(100)) }, new EventEffectBase[] { new AddGoldEffect(new AddGoldParam(-100)) }, new EventEffectBase[] { new AddGoldEffect(new AddGoldParam(250)) }, probability: 0f) }); BackpackInventoryData inventory = new BackpackInventoryData { Gold = 120 }; EventOptionExecutionResult result = executor.Execute( eventItem, 0, CreateContext(), inventory); Assert.That(result.IsAccepted, Is.True); Assert.That(result.IsProbabilitySuccess, Is.False); Assert.That(inventory.Gold, Is.EqualTo(20)); } [Test] public void Execute_Removes_Only_Loose_Components_Of_Requested_Rarity() { EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 2, "工匠", "测试事件", new[] { new EventOption( "交出 2 个白色组件", new EventRequirementBase[] { new CompCountAtLeastRequirement(new CompCountAtLeastParam(2, RarityType.White)) }, new EventEffectBase[] { new RemoveRandomCompsEffect(new RemoveRandomCompsParam(2, RarityType.White)) }, Array.Empty()) }); BackpackInventoryData inventory = new BackpackInventoryData { MuzzleComponents = new List { new MuzzleCompItemData { InstanceId = 10001, Name = "已装配白枪口", Rarity = RarityType.White, IsAssembledIntoTower = true }, new MuzzleCompItemData { InstanceId = 10002, Name = "未装配白枪口", Rarity = RarityType.White, IsAssembledIntoTower = false } }, BearingComponents = new List { new BearingCompItemData { InstanceId = 20001, Name = "未装配白轴承", Rarity = RarityType.White, IsAssembledIntoTower = false }, new BearingCompItemData { InstanceId = 20002, Name = "绿色轴承", Rarity = RarityType.Green, IsAssembledIntoTower = false } } }; EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); Assert.That(result.IsAccepted, Is.True); Assert.That(inventory.MuzzleComponents.Count, Is.EqualTo(1)); Assert.That(inventory.MuzzleComponents[0].InstanceId, Is.EqualTo(10001)); Assert.That(inventory.BearingComponents.Count, Is.EqualTo(1)); Assert.That(inventory.BearingComponents[0].InstanceId, Is.EqualTo(20002)); } [Test] public void Execute_AddRandomComponents_Is_Stable_For_Same_Context() { BindInventoryGeneration(); EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 3, "奖励组件", "测试事件", new[] { new EventOption( "获得两个蓝色组件", Array.Empty(), Array.Empty(), new EventEffectBase[] { new AddRandomCompsEffect(new AddRandomCompsParam(2, RarityType.Blue)) }) }); RunNodeExecutionContext context = CreateContext(); BackpackInventoryData firstInventory = new BackpackInventoryData(); BackpackInventoryData secondInventory = new BackpackInventoryData(); EventOptionExecutionResult firstResult = executor.Execute(eventItem, 0, context, firstInventory); EventOptionExecutionResult secondResult = executor.Execute(eventItem, 0, context, secondInventory); Assert.That(firstResult.IsAccepted, Is.True); Assert.That(secondResult.IsAccepted, Is.True); Assert.That(BuildComponentSignature(secondInventory), Is.EqualTo(BuildComponentSignature(firstInventory))); } [Test] public void Execute_ComponentExchange_Removes_Two_And_Returns_One_In_Configured_Range() { BindInventoryGeneration(); EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 5, "组件交换", "测试事件", new[] { new EventOption( "交出 2 个绿色组件,获得 1 个绿色或蓝色组件", new EventRequirementBase[] { new CompCountAtLeastRequirement(new CompCountAtLeastParam(2, RarityType.Green)) }, new EventEffectBase[] { new RemoveRandomCompsEffect(new RemoveRandomCompsParam(2, RarityType.Green)) }, new EventEffectBase[] { new AddRandomCompsEffect(new AddRandomCompsParam(1, RarityType.Green, RarityType.Blue)) }) }); BackpackInventoryData inventory = new BackpackInventoryData { MuzzleComponents = new List { new MuzzleCompItemData { InstanceId = 10001, Name = "绿枪口 A", Rarity = RarityType.Green, IsAssembledIntoTower = false } }, BearingComponents = new List { new BearingCompItemData { InstanceId = 20001, Name = "绿轴承 B", Rarity = RarityType.Green, IsAssembledIntoTower = false } } }; EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); Assert.That(result.IsAccepted, Is.True); Assert.That(CountLooseComponentsOfRarity(inventory, RarityType.Green), Is.LessThanOrEqualTo(1)); Assert.That(CountAllComponents(inventory), Is.EqualTo(1)); TowerCompItemData rewardItem = GetOnlyComponent(inventory); Assert.That(rewardItem.Rarity == RarityType.Green || rewardItem.Rarity == RarityType.Blue, Is.True); } [Test] public void Execute_EnduranceForGold_Applies_Cost_Before_Reward() { EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 6, "耐久换金币", "测试事件", new[] { new EventOption( "扣耐久换金币", new EventRequirementBase[] { new TowerCountAtLeastRequirement(new TowerCountAtLeastParam(1)) }, new EventEffectBase[] { new DamageRandomTowerEnduranceEffect(new DamageRandomTowerEnduranceParam(1, 20)) }, new EventEffectBase[] { new AddGoldEffect(new AddGoldParam(50)) }) }); BackpackInventoryData inventory = CreateParticipantInventory(endurance: 30f); inventory.Gold = 10; EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); Assert.That(result.IsAccepted, Is.True); Assert.That(inventory.Gold, Is.EqualTo(60)); Assert.That(inventory.MuzzleComponents[0].Endurance, Is.EqualTo(10f)); Assert.That(inventory.BearingComponents[0].Endurance, Is.EqualTo(10f)); Assert.That(inventory.BaseComponents[0].Endurance, Is.EqualTo(10f)); } [Test] public void Execute_Damaged_Participant_Tower_Can_Be_Cleaned_Up_By_Procedure_Service() { EventOptionExecutor executor = new EventOptionExecutor(); EventItem eventItem = new EventItem( 4, "耐久惩罚", "测试事件", new[] { new EventOption( "损坏防御塔", Array.Empty(), Array.Empty(), new EventEffectBase[] { new DamageRandomTowerEnduranceEffect(new DamageRandomTowerEnduranceParam(1, 20)) }) }); BackpackInventoryData inventory = CreateParticipantInventory(endurance: 10f); EventOptionExecutionResult result = executor.Execute(eventItem, 0, CreateContext(), inventory); ProcedureMainParticipantTowerCleanupResult cleanupResult = ProcedureMainParticipantTowerCleanupService.RemoveBrokenParticipantTowers(inventory, 4); Assert.That(result.IsAccepted, Is.True); Assert.That(cleanupResult.HasAnyRemovedTower, Is.True); Assert.That(inventory.ParticipantTowerInstanceIds, Is.Empty); } [Test] public void EventTable_Uses_Only_Runtime_Supported_Types() { string filePath = ResolveRepoFilePath("Assets/GameMain/DataTables/Event.txt"); Assert.That(File.Exists(filePath), Is.True, filePath); HashSet supportedRequirementTypes = new HashSet { "GoldAtLeast", "CompCountAtLeast", "TowerCountAtLeast" }; HashSet supportedEffectTypes = new HashSet { "AddGold", "RemoveRandomComps", "AddRandomComps", "DamageRandomTowersEndurance" }; foreach (string line in File.ReadLines(filePath)) { if (string.IsNullOrWhiteSpace(line) || line.StartsWith("#", StringComparison.Ordinal)) { continue; } DREvent row = new DREvent(); Assert.That(row.ParseDataRow(line, null), Is.True, line); JArray options = JArray.Parse(row.OptionsRaw); for (int i = 0; i < options.Count; i++) { JObject optionObject = options[i] as JObject; Assert.That(optionObject, Is.Not.Null, $"EventId={row.Id}, OptionIndex={i}"); AssertSupportedTypes( row.Id, i, optionObject["requirements"] as JArray, supportedRequirementTypes, isRequirement: true); AssertSupportedTypes( row.Id, i, optionObject["costEffects"] as JArray, supportedEffectTypes, isRequirement: false); AssertSupportedTypes( row.Id, i, optionObject["rewardEffects"] as JArray, supportedEffectTypes, isRequirement: false); } } } [Test] public void EventNodeComponent_Selects_Same_Event_For_Same_Context() { GameObject gameObject = new GameObject("EventNodeComponentTests"); try { EventNodeComponent component = gameObject.AddComponent(); List eventItems = GetPrivateField>(component, "_eventItems"); eventItems.Add(new EventItem(11, "事件一", string.Empty, Array.Empty())); eventItems.Add(new EventItem(12, "事件二", string.Empty, Array.Empty())); eventItems.Add(new EventItem(13, "事件三", string.Empty, Array.Empty())); MethodInfo method = typeof(EventNodeComponent).GetMethod( "SelectActiveEvent", BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(method, Is.Not.Null); RunNodeExecutionContext context = CreateContext(); EventItem first = (EventItem)method.Invoke(component, new object[] { context }); EventItem second = (EventItem)method.Invoke(component, new object[] { context }); Assert.That(second, Is.Not.Null); Assert.That(second.Id, Is.EqualTo(first.Id)); } finally { Object.DestroyImmediate(gameObject); } } private void BindInventoryGeneration() { _originalInventoryGeneration = GameEntry.InventoryGeneration; _inventoryGenerationObject = new GameObject("EventInventoryGenerationTests"); InventoryGenerationComponent component = _inventoryGenerationObject.AddComponent(); SetPrivateField(component, "_muzzleCompTable", new FakeDataTable( CreateMuzzleRow(1, "火焰枪口", "[Fire,Ice,Crit]"), CreateMuzzleRow(2, "暴击枪口", "[Crit,Shatter,Execution]"))); SetPrivateField(component, "_bearingCompTable", new FakeDataTable( CreateBearingRow(1, "寒冰轴承", "[Ice,Shatter]"), CreateBearingRow(2, "穿透轴承", "[Pierce,Crit]"))); SetPrivateField(component, "_baseCompTable", new FakeDataTable( CreateBaseRow(1, "迅捷底座", "[Fire,Crit]"), CreateBaseRow(2, "处决底座", "[Execution,Ice]"))); SetStaticInventoryGeneration(component); } private static RunNodeExecutionContext CreateContext() { return new RunNodeExecutionContext { RunId = "testrun", RunSeed = 12345, NodeId = 101, NodeType = RunNodeType.Event, SequenceIndex = 3 }; } private static BackpackInventoryData CreateParticipantInventory(float endurance) { return new BackpackInventoryData { MuzzleComponents = new List { new MuzzleCompItemData { InstanceId = 10001, Name = "枪口", Rarity = RarityType.Blue, Endurance = endurance, IsAssembledIntoTower = true } }, BearingComponents = new List { new BearingCompItemData { InstanceId = 20001, Name = "轴承", Rarity = RarityType.Blue, Endurance = endurance, IsAssembledIntoTower = true } }, BaseComponents = new List { new BaseCompItemData { InstanceId = 30001, Name = "底座", Rarity = RarityType.Blue, Endurance = endurance, IsAssembledIntoTower = true } }, Towers = new List { new TowerItemData { InstanceId = 90001, Name = "测试防御塔", MuzzleComponentInstanceId = 10001, BearingComponentInstanceId = 20001, BaseComponentInstanceId = 30001 } }, ParticipantTowerInstanceIds = new List { 90001 } }; } private static string BuildComponentSignature(BackpackInventoryData inventory) { IEnumerable items = inventory.MuzzleComponents.Cast() .Concat(inventory.BearingComponents) .Concat(inventory.BaseComponents) .OrderBy(item => item.InstanceId); return string.Join( "|", items.Select(item => { string tags = item.Tags == null || item.Tags.Length <= 0 ? string.Empty : string.Join(",", item.Tags.Select(tag => tag.ToString())); return $"{item.InstanceId}:{item.SlotType}:{item.ConfigId}:{item.Name}:{item.Rarity}:{tags}"; })); } private static int CountLooseComponentsOfRarity(BackpackInventoryData inventory, RarityType rarity) { return GetAllComponents(inventory) .Count(component => !component.IsAssembledIntoTower && component.Rarity == rarity); } private static int CountAllComponents(BackpackInventoryData inventory) { return GetAllComponents(inventory).Count(); } private static TowerCompItemData GetOnlyComponent(BackpackInventoryData inventory) { return GetAllComponents(inventory).Single(); } private static IEnumerable GetAllComponents(BackpackInventoryData inventory) { return inventory.MuzzleComponents.Cast() .Concat(inventory.BearingComponents) .Concat(inventory.BaseComponents); } private static void AssertSupportedTypes( int eventId, int optionIndex, JArray entries, HashSet supportedTypes, bool isRequirement) { if (entries == null) { return; } for (int i = 0; i < entries.Count; i++) { JObject entry = entries[i] as JObject; Assert.That(entry, Is.Not.Null, $"EventId={eventId}, OptionIndex={optionIndex}, EntryIndex={i}"); string rawType = entry.Value("type"); Assert.That( supportedTypes.Contains(rawType), Is.True, $"{(isRequirement ? "Requirement" : "Effect")} type '{rawType}' is not supported at runtime. EventId={eventId}, OptionIndex={optionIndex}, EntryIndex={i}"); } } private static void SetStaticInventoryGeneration(InventoryGenerationComponent component) { FieldInfo backingField = typeof(GameEntry).GetField( "k__BackingField", BindingFlags.Static | BindingFlags.NonPublic); Assert.That(backingField, Is.Not.Null); backingField.SetValue(null, component); } private static TField GetPrivateField(object instance, string fieldName) { FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null, fieldName); return (TField)field.GetValue(instance); } private static void SetPrivateField(object instance, string fieldName, object value) { FieldInfo field = instance.GetType().GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic); Assert.That(field, Is.Not.Null, fieldName); field.SetValue(instance, value); } private static string ResolveRepoFilePath(string relativePath) { DirectoryInfo directory = new DirectoryInfo(Directory.GetCurrentDirectory()); while (directory != null) { string candidate = Path.Combine(directory.FullName, relativePath); if (File.Exists(candidate)) { return candidate; } directory = directory.Parent; } return Path.Combine(Directory.GetCurrentDirectory(), relativePath); } private static DRMuzzleComp CreateMuzzleRow(int id, string name, string possibleTags) { DRMuzzleComp row = new DRMuzzleComp(); Assert.That( row.ParseDataRow($"\t{id}\t\t{name}\t[10,20,30,40,50]\t3\t0.15\tNormalBullet\t\t{possibleTags}", null), Is.True); return row; } private static DRBearingComp CreateBearingRow(int id, string name, string possibleTags) { DRBearingComp row = new DRBearingComp(); Assert.That( row.ParseDataRow($"\t{id}\t\t{name}\t[1,2,3,4,5]\t0.5\t[10,20,30,40,50]\t1\t\t{possibleTags}", null), Is.True); return row; } private static DRBaseComp CreateBaseRow(int id, string name, string possibleTags) { DRBaseComp row = new DRBaseComp(); Assert.That( row.ParseDataRow($"\t{id}\t\t{name}\t[2,4,6,8,10]\t-0.25\tFire\t\t{possibleTags}", null), Is.True); return row; } private sealed class FakeDataTable : IDataTable where TRow : class, IDataRow { private readonly Dictionary _rowsById = new(); public FakeDataTable(params TRow[] rows) { if (rows == null) { return; } for (int i = 0; i < rows.Length; i++) { TRow row = rows[i]; if (row != null) { _rowsById[row.Id] = row; } } } public string Name => typeof(TRow).Name; public string FullName => typeof(TRow).FullName; public Type Type => typeof(TRow); public int Count => _rowsById.Count; public TRow this[int id] => GetDataRow(id); public TRow MinIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[0]); public TRow MaxIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[^1]); public bool HasDataRow(int id) => _rowsById.ContainsKey(id); public bool HasDataRow(Predicate condition) => GetDataRow(condition) != null; public TRow GetDataRow(int id) => _rowsById.TryGetValue(id, out TRow row) ? row : null; public TRow GetDataRow(Predicate condition) { if (condition == null) { return null; } foreach (TRow row in _rowsById.Values) { if (row != null && condition(row)) { return row; } } return null; } public TRow[] GetDataRows(Predicate condition) { List results = new(); GetDataRows(condition, results); return results.ToArray(); } public void GetDataRows(Predicate condition, List results) { results?.Clear(); if (condition == null || results == null) { return; } foreach (TRow row in _rowsById.Values) { if (row != null && condition(row)) { results.Add(row); } } } public TRow[] GetDataRows(Comparison comparison) { List results = new(); GetDataRows(comparison, results); return results.ToArray(); } public void GetDataRows(Comparison comparison, List results) { results?.Clear(); if (results == null) { return; } results.AddRange(_rowsById.Values); if (comparison != null) { results.Sort(comparison); } } public TRow[] GetDataRows(Predicate condition, Comparison comparison) { List results = new(); GetDataRows(condition, comparison, results); return results.ToArray(); } public void GetDataRows(Predicate condition, Comparison comparison, List results) { GetDataRows(condition, results); if (results != null && comparison != null) { results.Sort(comparison); } } public TRow[] GetAllDataRows() { List results = new(); GetAllDataRows(results); return results.ToArray(); } public void GetAllDataRows(List results) { results?.Clear(); if (results == null) { return; } foreach (int id in GetOrderedIds()) { results.Add(_rowsById[id]); } } public bool AddDataRow(string dataRowString, object userData) => throw new NotSupportedException(); public bool AddDataRow(byte[] dataRowBytes, int startIndex, int length, object userData) => throw new NotSupportedException(); public bool RemoveDataRow(int id) => _rowsById.Remove(id); public void RemoveAllDataRows() { _rowsById.Clear(); } public IEnumerator GetEnumerator() { foreach (int id in GetOrderedIds()) { yield return _rowsById[id]; } } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } private int[] GetOrderedIds() { int[] ids = _rowsById.Keys.ToArray(); Array.Sort(ids); return ids; } } } }