using System; using System.Collections; using System.Collections.Generic; using System.Linq; using System.Reflection; using GameFramework.DataTable; using GeometryTD.CustomComponent; using GeometryTD.DataTable; using GeometryTD.Definition; using GeometryTD.UI; using NUnit.Framework; using UnityEngine; using Object = UnityEngine.Object; namespace GeometryTD.Tests.EditMode { public sealed class InventoryGenerationStabilityTests { private GameObject _gameObject; private InventoryGenerationComponent _component; [SetUp] public void SetUp() { TagDefinitionRegistry.ResetToDefaults(); TagGenerationRuleRegistry.ResetToDefaults(); RarityTagBudgetRuleRegistry.ResetToDefaults(); _gameObject = new GameObject("InventoryGenerationStabilityTests"); _component = _gameObject.AddComponent(); BindTables(_component); } [TearDown] public void TearDown() { TagDefinitionRegistry.ResetToDefaults(); TagGenerationRuleRegistry.ResetToDefaults(); RarityTagBudgetRuleRegistry.ResetToDefaults(); if (_gameObject != null) { Object.DestroyImmediate(_gameObject); _gameObject = null; _component = null; } } [Test] public void BuildShopGoods_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() { string first = BuildShopSignature(runSeed: 1001, sequenceIndex: 3); string second = BuildShopSignature(runSeed: 1001, sequenceIndex: 3); Assert.That(second, Is.EqualTo(first)); } [Test] public void BuildShopGoods_Distinguishes_Different_RunSeed() { HashSet signatures = new HashSet(); for (int runSeed = 1001; runSeed < 1017; runSeed++) { signatures.Add(BuildShopSignature(runSeed, sequenceIndex: 3)); } Assert.That(signatures.Count, Is.GreaterThan(1)); } [Test] public void ResolveEnemyDrop_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() { string first = BuildEnemyDropSignature(runSeed: 2001, sequenceIndex: 5); string second = BuildEnemyDropSignature(runSeed: 2001, sequenceIndex: 5); Assert.That(second, Is.EqualTo(first)); } [Test] public void ResolveEnemyDrop_Distinguishes_Different_RunSeed() { HashSet signatures = new HashSet(); for (int runSeed = 2001; runSeed < 2033; runSeed++) { signatures.Add(BuildEnemyDropSignature(runSeed, sequenceIndex: 5)); } Assert.That(signatures.Count, Is.GreaterThan(1)); } [Test] public void BuildRewardCandidates_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() { string first = BuildRewardCandidateSignature(runSeed: 3001, sequenceIndex: 7); string second = BuildRewardCandidateSignature(runSeed: 3001, sequenceIndex: 7); Assert.That(second, Is.EqualTo(first)); } [Test] public void BuildRewardCandidates_Distinguishes_Different_RunSeed() { HashSet signatures = new HashSet(); for (int runSeed = 3001; runSeed < 3017; runSeed++) { signatures.Add(BuildRewardCandidateSignature(runSeed, sequenceIndex: 7)); } Assert.That(signatures.Count, Is.GreaterThan(1)); } [Test] public void DropPoolRoller_Is_Reproducible_When_DropPool_Row_Order_Changes() { DROutGameDropPool whiteRow = CreateDropPoolRow(1, "MuzzleComp", 1, "[100,0,0,0,0]"); DROutGameDropPool greenRow = CreateDropPoolRow(2, "BearingComp", 1, "[0,100,0,0,0]"); DROutGameDropPool blueRow = CreateDropPoolRow(3, "BaseComp", 1, "[0,0,100,0,0]"); DROutGameDropPool purpleRow = CreateDropPoolRow(4, "MuzzleComp", 2, "[0,0,0,100,0]"); DROutGameDropPool redRow = CreateDropPoolRow(5, "BearingComp", 2, "[0,0,0,0,100]"); DropPoolRoller forwardOrderRoller = new DropPoolRoller( new InsertionOrderDataTable(whiteRow, greenRow, blueRow, purpleRow, redRow)); DropPoolRoller reverseOrderRoller = new DropPoolRoller( new InsertionOrderDataTable(redRow, purpleRow, blueRow, greenRow, whiteRow)); for (int seed = 1; seed <= 64; seed++) { bool forwardRolled = forwardOrderRoller.TryRollRow( 8, LevelThemeType.Plain, new System.Random(seed), out DROutGameDropPool forwardRow, out RarityType forwardRarity); bool reverseRolled = reverseOrderRoller.TryRollRow( 8, LevelThemeType.Plain, new System.Random(seed), out DROutGameDropPool reverseRow, out RarityType reverseRarity); Assert.That(forwardRolled, Is.True, $"seed={seed}"); Assert.That(reverseRolled, Is.True, $"seed={seed}"); Assert.That(reverseRarity, Is.EqualTo(forwardRarity), $"seed={seed}"); Assert.That(reverseRow.Id, Is.EqualTo(forwardRow.Id), $"seed={seed}"); } } [Test] public void RewardSelectFormUseCase_Uses_Stable_Order_And_Deterministic_Selection_Rotation() { RewardSelectFormUseCase useCase = new RewardSelectFormUseCase(); useCase.ConfigureRewardPool( new[] { CreateRewardRawData(1, "一号"), CreateRewardRawData(2, "二号"), CreateRewardRawData(3, "三号"), CreateRewardRawData(4, "四号") }, displayCount: 3, refreshCost: 0, allowRotateOnce: true, allowGiveUp: false); RewardSelectFormRawData initialModel = useCase.CreateInitialModel(); RewardSelectFormRawData refreshedModel = useCase.TryRotateSelection(); Assert.That(initialModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "一号", "二号", "三号" })); Assert.That(refreshedModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "四号", "一号", "二号" })); } private string BuildShopSignature(int runSeed, int sequenceIndex) { List goods = _component.BuildShopGoods(4, runSeed, sequenceIndex); return string.Join("|", goods.Select(BuildGoodsSignaturePart)); } private string BuildEnemyDropSignature(int runSeed, int sequenceIndex) { int nextDropOrdinal = 0; EnemyDropResult result = _component.ResolveEnemyDrop( new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain), runSeed, sequenceIndex, ref nextDropOrdinal); return BuildDropSignaturePart(result); } private string BuildRewardCandidateSignature(int runSeed, int sequenceIndex) { int nextRewardOrdinal = 0; IReadOnlyList candidates = _component.BuildRewardCandidates( 8, LevelThemeType.Plain, 3, runSeed, sequenceIndex, ref nextRewardOrdinal); return string.Join("|", candidates.Select(BuildItemSignaturePart)); } private static string BuildGoodsSignaturePart(GoodsItemRawData goods) { return $"{goods.GoodsIndex}:{goods.Price}:{BuildItemSignaturePart(goods.SourceItem)}"; } private static string BuildDropSignaturePart(EnemyDropResult result) { return $"{result.Coin}:{result.Gold}:{BuildItemSignaturePart(result.LootItem)}"; } private static string BuildItemSignaturePart(TowerCompItemData item) { if (item == null) { return "null"; } 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 RewardSelectItemRawData CreateRewardRawData(long instanceId, string title) { return new RewardSelectItemRawData { Title = title, SlotType = TowerCompSlotType.Muzzle, SourceItem = new MuzzleCompItemData { InstanceId = instanceId, Name = title, } }; } private static void BindTables(InventoryGenerationComponent component) { SetPrivateField(component, "_shopPriceTable", new FakeDataTable( CreateShopPriceRow(1, RarityType.Blue, 30, 35), CreateShopPriceRow(2, RarityType.Purple, 60, 70))); SetPrivateField(component, "_dropPoolTable", new FakeDataTable( CreateDropPoolRow(1, "MuzzleComp", 1, "[50,40,30,10,0]"), CreateDropPoolRow(2, "MuzzleComp", 2, "[10,30,50,20,0]"), CreateDropPoolRow(3, "BearingComp", 1, "[20,40,20,10,0]"), CreateDropPoolRow(4, "BaseComp", 1, "[20,20,40,10,0]"))); 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]"))); } 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 DRShopPrice CreateShopPriceRow(int id, RarityType rarity, int minPrice, int maxPrice) { DRShopPrice row = new DRShopPrice(); Assert.That(row.ParseDataRow($"\t{id}\t\t{rarity}\t{minPrice}\t{maxPrice}", null), Is.True); return row; } private static DROutGameDropPool CreateDropPoolRow(int id, string itemType, int itemId, string weights) { DROutGameDropPool row = new DROutGameDropPool(); Assert.That( row.ParseDataRow($"\t{id}\t\tPlain\t{itemType}\t{itemId}\t{weights}\t[1,1,1,1,1]\t[99,99,99,99,99]", null), Is.True); return row; } 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 static DREnemy CreateEnemyRow() { DREnemy row = new DREnemy(); Assert.That(row.ParseDataRow("\t1\t\t1\t100\t1\t1\t3\t5\t1", 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; } } private sealed class InsertionOrderDataTable : IDataTable where TRow : class, IDataRow { private readonly List _rows = new(); private readonly Dictionary _rowsById = new(); public InsertionOrderDataTable(params TRow[] rows) { if (rows == null) { return; } for (int i = 0; i < rows.Length; i++) { TRow row = rows[i]; if (row == null) { continue; } _rows.Add(row); _rowsById[row.Id] = row; } } public string Name => typeof(TRow).Name; public string FullName => typeof(TRow).FullName; public Type Type => typeof(TRow); public int Count => _rows.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; } for (int i = 0; i < _rows.Count; i++) { TRow row = _rows[i]; 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; } for (int i = 0; i < _rows.Count; i++) { TRow row = _rows[i]; 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(_rows); 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; } results.AddRange(_rows); } 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) { if (!_rowsById.TryGetValue(id, out TRow row)) { return false; } _rowsById.Remove(id); return _rows.Remove(row); } public void RemoveAllDataRows() { _rows.Clear(); _rowsById.Clear(); } public IEnumerator GetEnumerator() { for (int i = 0; i < _rows.Count; i++) { yield return _rows[i]; } } IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); } private int[] GetOrderedIds() { int[] ids = _rowsById.Keys.ToArray(); Array.Sort(ids); return ids; } } } }