diff --git a/Assets/GameMain/Scripts/Definition/Tag/Generation/RarityTagBudgetRuleRegistry.cs b/Assets/GameMain/Scripts/Definition/Tag/Generation/RarityTagBudgetRuleRegistry.cs index 194757e..395447e 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Generation/RarityTagBudgetRuleRegistry.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Generation/RarityTagBudgetRuleRegistry.cs @@ -38,10 +38,10 @@ namespace GeometryTD.Definition return new Dictionary { [RarityType.White] = CreateRule(RarityType.White, 0, 1), - [RarityType.Green] = CreateRule(RarityType.Green, 1, 1), - [RarityType.Blue] = CreateRule(RarityType.Blue, 1, 2), - [RarityType.Purple] = CreateRule(RarityType.Purple, 2, 2), - [RarityType.Red] = CreateRule(RarityType.Red, 2, 3) + [RarityType.Green] = CreateRule(RarityType.Green, 0, 2), + [RarityType.Blue] = CreateRule(RarityType.Blue, 1, 3), + [RarityType.Purple] = CreateRule(RarityType.Purple, 1, 3), + [RarityType.Red] = CreateRule(RarityType.Red, 2, 4) }; } diff --git a/Assets/GameMain/Scripts/Definition/Tag/Generation/TagGenerationRuleRegistry.cs b/Assets/GameMain/Scripts/Definition/Tag/Generation/TagGenerationRuleRegistry.cs index 019aad7..f6a38e7 100644 --- a/Assets/GameMain/Scripts/Definition/Tag/Generation/TagGenerationRuleRegistry.cs +++ b/Assets/GameMain/Scripts/Definition/Tag/Generation/TagGenerationRuleRegistry.cs @@ -41,10 +41,10 @@ namespace GeometryTD.Definition [TagType.BurnSpread] = CreateRule(TagType.BurnSpread, RarityType.White, 20), [TagType.IgniteBurst] = CreateRule(TagType.IgniteBurst, RarityType.Green, 15), [TagType.Inferno] = CreateRule(TagType.Inferno, RarityType.Purple, 5), - [TagType.Ice] = CreateRule(TagType.Ice, RarityType.White, 20), + [TagType.Ice] = CreateRule(TagType.Ice, RarityType.White, 1), [TagType.FreezeMask] = CreateRule(TagType.FreezeMask, RarityType.White, 20), [TagType.Shatter] = CreateRule(TagType.Shatter, RarityType.Green, 15), - [TagType.AbsoluteZero] = CreateRule(TagType.AbsoluteZero, RarityType.Purple, 5), + [TagType.AbsoluteZero] = CreateRule(TagType.AbsoluteZero, RarityType.Purple, 1), [TagType.Pierce] = CreateRule(TagType.Pierce, RarityType.White, 20), [TagType.Crit] = CreateRule(TagType.Crit, RarityType.White, 20), [TagType.Overpenetrate] = CreateRule(TagType.Overpenetrate, RarityType.Green, 15), diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/FixedRunNodeSequenceBuilder.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/FixedRunNodeSequenceBuilder.cs index 8909b7d..e17c66e 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/FixedRunNodeSequenceBuilder.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/FixedRunNodeSequenceBuilder.cs @@ -49,4 +49,4 @@ namespace GeometryTD.Procedure }; } } -} +} \ No newline at end of file diff --git a/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs b/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs index d6b3a34..0b96bfd 100644 --- a/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs +++ b/Assets/GameMain/Scripts/Utility/InventorySeedUtility.cs @@ -24,14 +24,14 @@ namespace GeometryTD.CustomUtility InstanceId = 10001, ConfigId = 1, Name = "元素枪口", - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green), + Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue), Endurance = 90f, IsAssembledIntoTower = true, AttackDamage = new[] { 200, 300, 400, 500, 800 }, DamageRandomRate = 0.05f, AttackMethodType = AttackMethodType.NormalBullet, Constraint = string.Empty, - Tags = ResolveSeedTags(FirePool, RarityType.Green, 10001, 1) + Tags = ResolveSeedTags(FirePool, RarityType.Blue, 10001, 1) }; BearingCompItemData bearing = new BearingCompItemData @@ -39,13 +39,13 @@ namespace GeometryTD.CustomUtility InstanceId = 20001, ConfigId = 1, Name = "元素轴承", - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green), + Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue), Endurance = 1f, IsAssembledIntoTower = true, RotateSpeed = new[] { 100f, 120f, 130f, 140f, 150f }, AttackRange = new[] { 3f, 4f, 5f, 6f, 8f }, Constraint = string.Empty, - Tags = ResolveSeedTags(FirePool, RarityType.Green, 20001, 1) + Tags = ResolveSeedTags(FirePool, RarityType.Blue, 20001, 1) }; BaseCompItemData baseComp = new BaseCompItemData @@ -53,13 +53,13 @@ namespace GeometryTD.CustomUtility InstanceId = 30001, ConfigId = 1, Name = "元素底座", - Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Green), + Rarity = InventoryRarityRuleService.NormalizeComponentRarity(RarityType.Blue), Endurance = 88f, IsAssembledIntoTower = true, AttackSpeed = new[] { 1f, 2f, 3f, 3.5f, 0.7f }, AttackPropertyType = AttackPropertyType.Fire, Constraint = string.Empty, - Tags = ResolveSeedTags(FirePool, RarityType.Green, 30001, 1) + Tags = ResolveSeedTags(FirePool, RarityType.Blue, 30001, 1) }; TowerItemData tower = new TowerItemData diff --git a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs index 9e51d88..af133d1 100644 --- a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs +++ b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs @@ -3,6 +3,7 @@ using System.Reflection; using GeometryTD.CustomComponent; using GeometryTD.DataTable; using GeometryTD.Definition; +using GeometryTD.UI; using NUnit.Framework; using UnityEngine; @@ -110,6 +111,198 @@ namespace GeometryTD.Tests.EditMode Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent)); } + [Test] + public void BuildSettlementContext_On_Full_BaseHp_Win_Adds_BonusGold_And_Opens_RewardSelection() + { + CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + + CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext( + didCombatWin: true, + currentLevel: level, + defeatedEnemyCount: 5, + resourceStore: resourceStore); + + Assert.That(settlementContext.Result.DidCombatWin, Is.True); + Assert.That(settlementContext.Result.FinalBaseHp, Is.EqualTo(100)); + Assert.That(settlementContext.Result.MaxBaseHp, Is.EqualTo(100)); + Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(39)); + Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(39)); + Assert.That(settlementContext.Summary.GainedGold, Is.EqualTo(39)); + Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.True); + Assert.That(settlementContext.Flags.DidEnterRewardSelection, Is.False); + } + + [Test] + public void BuildSettlementContext_On_High_BaseHp_Win_Adds_SettlementGold_Without_RewardSelection() + { + CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + SetCurrentBaseHp(resourceStore, 90); + + CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext( + didCombatWin: true, + currentLevel: level, + defeatedEnemyCount: 3, + resourceStore: resourceStore); + + Assert.That(settlementContext.Result.FinalBaseHp, Is.EqualTo(90)); + Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(33)); + Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(33)); + Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.False); + } + + [Test] + public void BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection() + { + CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + resourceStore.AddEnemyDefeatedReward(gainedCoin: 0, gainedGold: 7); + + CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext( + didCombatWin: false, + currentLevel: level, + defeatedEnemyCount: 3, + resourceStore: resourceStore); + + Assert.That(settlementContext.Result.DidCombatWin, Is.False); + Assert.That(settlementContext.Result.GainedGold, Is.EqualTo(7)); + Assert.That(settlementContext.Result.RewardInventory.Gold, Is.EqualTo(7)); + Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.False); + } + + [Test] + public void TryPrepareRewardSelection_Returns_False_When_Required_Dependency_Is_Missing() + { + CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatSettlementContext settlementContext = new CombatSettlementContext(); + settlementContext.Flags.ShouldOpenRewardSelection = true; + + bool prepared = new CombatSettlementService().TryPrepareRewardSelection( + settlementContext, + null, + displayPhaseIndex: 1, + themeType: LevelThemeType.Plain, + rewardSelectFormUseCase: new RewardSelectFormUseCase(), + onRewardSelected: _ => { }, + onGiveUp: null); + + Assert.That(prepared, Is.False); + Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.True); + Assert.That(settlementContext.Flags.DidEnterRewardSelection, Is.False); + } + + [Test] + public void ApplySelectedReward_Appends_Selected_Component_To_RewardInventory() + { + CombatSettlementContext settlementContext = new CombatSettlementContext(); + settlementContext.Result.RewardInventory = new BackpackInventoryData(); + MuzzleCompItemData reward = new MuzzleCompItemData + { + InstanceId = 70001, + Name = "奖励枪口" + }; + + new CombatSettlementService().ApplySelectedReward( + settlementContext, + new RewardSelectItemRawData + { + RewardId = reward.InstanceId, + SlotType = TowerCompSlotType.Muzzle, + SourceItem = reward + }); + + Assert.That(settlementContext.Result.RewardInventory.MuzzleComponents.Count, Is.EqualTo(1)); + Assert.That(settlementContext.Result.RewardInventory.MuzzleComponents[0], Is.SameAs(reward)); + Assert.That(settlementContext.Summary.RewardInventory, Is.SameAs(settlementContext.Result.RewardInventory)); + } + + [Test] + public void CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + int muzzleCountBeforeCommit = inventoryComponent.GetInventorySnapshot().MuzzleComponents.Count; + + CombatSettlementService settlementService = new CombatSettlementService(); + CombatSettlementContext settlementContext = settlementService.BuildSettlementContext( + didCombatWin: true, + currentLevel: level, + defeatedEnemyCount: 4, + resourceStore: resourceStore); + MuzzleCompItemData reward = new MuzzleCompItemData + { + InstanceId = 70002, + Name = "结算奖励枪口" + }; + settlementService.ApplySelectedReward( + settlementContext, + new RewardSelectItemRawData + { + RewardId = reward.InstanceId, + SlotType = TowerCompSlotType.Muzzle, + SourceItem = reward + }); + + settlementService.CommitSettlementInventory(settlementContext); + + BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot(); + Assert.That(committedInventory.MuzzleComponents.Count, Is.EqualTo(muzzleCountBeforeCommit + 1)); + MuzzleCompItemData mergedReward = committedInventory.MuzzleComponents[^1]; + Assert.That(mergedReward.Name, Is.EqualTo("结算奖励枪口")); + Assert.That(mergedReward.SlotType, Is.EqualTo(TowerCompSlotType.Muzzle)); + Assert.That(mergedReward.InstanceId, Is.Not.EqualTo(70002)); + Assert.That(committedInventory.Gold, Is.EqualTo(39)); + Assert.That(GetTowerComponents(committedInventory, 90001).Muzzle.Endurance, Is.EqualTo(99f)); + } + + [Test] + public void CommitSettlementInventory_Is_Idempotent_After_First_Commit() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + + CombatSettlementService settlementService = new CombatSettlementService(); + CombatSettlementContext settlementContext = settlementService.BuildSettlementContext( + didCombatWin: true, + currentLevel: level, + defeatedEnemyCount: 2, + resourceStore: resourceStore); + + settlementService.CommitSettlementInventory(settlementContext); + BackpackInventoryData firstCommittedInventory = inventoryComponent.GetInventorySnapshot(); + float firstEndurance = GetTowerComponents(firstCommittedInventory, 90001).Muzzle.Endurance; + int firstAffectedTowerCount = settlementContext.Result.Endurance.AffectedTowerCount; + int firstGold = firstCommittedInventory.Gold; + + settlementService.CommitSettlementInventory(settlementContext); + BackpackInventoryData secondCommittedInventory = inventoryComponent.GetInventorySnapshot(); + + Assert.That(settlementContext.Flags.IsCommitted, Is.True); + Assert.That(GetTowerComponents(secondCommittedInventory, 90001).Muzzle.Endurance, Is.EqualTo(firstEndurance)); + Assert.That(settlementContext.Result.Endurance.AffectedTowerCount, Is.EqualTo(firstAffectedTowerCount)); + Assert.That(secondCommittedInventory.Gold, Is.EqualTo(firstGold)); + } + + private static void SetCurrentBaseHp(CombatRunResourceStore resourceStore, int currentBaseHp) + { + FieldInfo backingField = typeof(CombatRunResourceStore).GetField( + "k__BackingField", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(resourceStore, currentBaseHp); + } + private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory) { _originalPlayerInventory = GameEntry.PlayerInventory; diff --git a/Assets/Tests/EditMode/ComponentTagGenerationServiceTests.cs b/Assets/Tests/EditMode/ComponentTagGenerationServiceTests.cs index 5b61435..c93a706 100644 --- a/Assets/Tests/EditMode/ComponentTagGenerationServiceTests.cs +++ b/Assets/Tests/EditMode/ComponentTagGenerationServiceTests.cs @@ -175,6 +175,20 @@ namespace GeometryTD.Tests.EditMode TagGenerationRuleRegistry.ResetToDefaults(); } + [Test] + public void TagGenerationRuleRegistry_Defaults_Align_With_Current_TagTable_Baseline() + { + TagGenerationRuleRegistry.ResetToDefaults(); + + Assert.That(TagGenerationRuleRegistry.TryGetRule(TagType.Ice, out TagGenerationRule iceRule), Is.True); + Assert.That(iceRule.MinRarity, Is.EqualTo(RarityType.White)); + Assert.That(iceRule.Weight, Is.EqualTo(1)); + + Assert.That(TagGenerationRuleRegistry.TryGetRule(TagType.AbsoluteZero, out TagGenerationRule absoluteZeroRule), Is.True); + Assert.That(absoluteZeroRule.MinRarity, Is.EqualTo(RarityType.Purple)); + Assert.That(absoluteZeroRule.Weight, Is.EqualTo(1)); + } + [Test] public void ResolveRarityTagBudget_Uses_TableDriven_Range_Rules() { @@ -238,6 +252,28 @@ namespace GeometryTD.Tests.EditMode RarityTagBudgetRuleRegistry.ResetToDefaults(); } + [Test] + public void RarityTagBudgetRuleRegistry_Defaults_Align_With_Current_BudgetTable_Baseline() + { + RarityTagBudgetRuleRegistry.ResetToDefaults(); + + Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Green, out RarityTagBudgetRule greenRule), Is.True); + Assert.That(greenRule.MinCount, Is.EqualTo(0)); + Assert.That(greenRule.MaxCount, Is.EqualTo(2)); + + Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Blue, out RarityTagBudgetRule blueRule), Is.True); + Assert.That(blueRule.MinCount, Is.EqualTo(1)); + Assert.That(blueRule.MaxCount, Is.EqualTo(3)); + + Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Purple, out RarityTagBudgetRule purpleRule), Is.True); + Assert.That(purpleRule.MinCount, Is.EqualTo(1)); + Assert.That(purpleRule.MaxCount, Is.EqualTo(3)); + + Assert.That(RarityTagBudgetRuleRegistry.TryGetRule(RarityType.Red, out RarityTagBudgetRule redRule), Is.True); + Assert.That(redRule.MinCount, Is.EqualTo(2)); + Assert.That(redRule.MaxCount, Is.EqualTo(4)); + } + [Test] public void CreateSampleInventory_Generates_SeedTags_Within_Launch_Set_And_Matches_Tower_Stats() { diff --git a/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs b/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs new file mode 100644 index 0000000..edb6e1e --- /dev/null +++ b/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs @@ -0,0 +1,419 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using GameFramework.DataTable; +using GeometryTD.CustomComponent; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using NUnit.Framework; +using UnityEngine; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class PlayerInventoryTowerAssemblyServiceTests + { + private GameObject _inventoryObject; + private PlayerInventoryComponent _originalPlayerInventory; + + [TearDown] + public void TearDown() + { + SetStaticPlayerInventory(_originalPlayerInventory); + if (_inventoryObject != null) + { + UnityEngine.Object.DestroyImmediate(_inventoryObject); + _inventoryObject = null; + } + } + + [Test] + public void TryAssembleTower_Builds_Expected_Rarity_Stats_And_Tags() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(new BackpackInventoryData + { + MuzzleComponents = + { + new MuzzleCompItemData + { + InstanceId = 10001, + ConfigId = 1, + Name = "测试枪口", + Rarity = RarityType.Green, + AttackDamage = new[] { 10, 20, 30, 40, 50 }, + DamageRandomRate = 0.15f, + AttackMethodType = AttackMethodType.NormalBullet, + Tags = new[] { TagType.Fire } + } + }, + BearingComponents = + { + new BearingCompItemData + { + InstanceId = 20001, + ConfigId = 1, + Name = "测试轴承", + Rarity = RarityType.Blue, + RotateSpeed = new[] { 1f, 2f, 3f, 4f, 5f }, + AttackRange = new[] { 10f, 20f, 30f, 40f, 50f }, + Tags = new[] { TagType.Ice } + } + }, + BaseComponents = + { + new BaseCompItemData + { + InstanceId = 30001, + ConfigId = 1, + Name = "测试底座", + Rarity = RarityType.Purple, + AttackSpeed = new[] { 2f, 4f, 6f, 8f, 10f }, + AttackPropertyType = AttackPropertyType.Fire, + Tags = new[] { TagType.Fire } + } + } + }); + InjectAssemblyTables( + inventoryComponent, + new FakeDataTable(CreateMuzzleRow()), + new FakeDataTable(CreateBearingRow()), + new FakeDataTable(CreateBaseRow())); + + bool assembled = inventoryComponent.TryAssembleTower(10001, 20001, 30001, out TowerItemData tower); + BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot(); + + Assert.That(assembled, Is.True); + Assert.That(tower, Is.Not.Null); + Assert.That(tower.Rarity, Is.EqualTo(RarityType.Blue)); + Assert.That(tower.Stats.AttackDamage, Is.EqualTo(new[] { 20, 23, 26, 29, 32 })); + Assert.That(tower.Stats.RotateSpeed, Is.EqualTo(new[] { 3f, 3.5f, 4f, 4.5f, 5f })); + Assert.That(tower.Stats.AttackRange, Is.EqualTo(new[] { 30f, 31f, 32f, 33f, 34f })); + Assert.That(tower.Stats.AttackSpeed, Is.EqualTo(new[] { 8f, 7.75f, 7.5f, 7.25f, 7f })); + Assert.That(tower.Stats.DamageRandomRate, Is.EqualTo(0.15f)); + Assert.That(tower.Stats.AttackMethodType, Is.EqualTo(AttackMethodType.NormalBullet)); + Assert.That(tower.Stats.AttackPropertyType, Is.EqualTo(AttackPropertyType.Fire)); + Assert.That(tower.Stats.Tags, Is.EqualTo(new[] { TagType.Fire, TagType.Ice })); + Assert.That(tower.Stats.TagRuntimes, Has.Length.EqualTo(2)); + Assert.That(tower.Stats.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(tower.Stats.TagRuntimes[0].TotalStack, Is.EqualTo(2)); + Assert.That(tower.Stats.TagRuntimes[1].TagType, Is.EqualTo(TagType.Ice)); + Assert.That(tower.Stats.TagRuntimes[1].TotalStack, Is.EqualTo(1)); + Assert.That(committedInventory.Towers, Has.Count.EqualTo(1)); + Assert.That(committedInventory.MuzzleComponents[0].IsAssembledIntoTower, Is.True); + Assert.That(committedInventory.BearingComponents[0].IsAssembledIntoTower, Is.True); + Assert.That(committedInventory.BaseComponents[0].IsAssembledIntoTower, Is.True); + } + + [Test] + public void TryAssembleTower_Returns_False_When_Required_Config_Row_Is_Missing() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(new BackpackInventoryData + { + MuzzleComponents = + { + new MuzzleCompItemData + { + InstanceId = 10001, + ConfigId = 1, + Rarity = RarityType.White, + AttackDamage = new[] { 10, 20, 30, 40, 50 }, + AttackMethodType = AttackMethodType.NormalBullet + } + }, + BearingComponents = + { + new BearingCompItemData + { + InstanceId = 20001, + ConfigId = 1, + Rarity = RarityType.White, + RotateSpeed = new[] { 1f, 2f, 3f, 4f, 5f }, + AttackRange = new[] { 1f, 2f, 3f, 4f, 5f } + } + }, + BaseComponents = + { + new BaseCompItemData + { + InstanceId = 30001, + ConfigId = 999, + Rarity = RarityType.White, + AttackSpeed = new[] { 1f, 2f, 3f, 4f, 5f }, + AttackPropertyType = AttackPropertyType.Physics + } + } + }); + InjectAssemblyTables( + inventoryComponent, + new FakeDataTable(CreateMuzzleRow()), + new FakeDataTable(CreateBearingRow()), + new FakeDataTable(CreateBaseRow())); + + bool assembled = inventoryComponent.TryAssembleTower(10001, 20001, 30001, out TowerItemData tower); + BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot(); + + Assert.That(assembled, Is.False); + Assert.That(tower, Is.Null); + Assert.That(committedInventory.Towers, Has.Count.EqualTo(0)); + Assert.That(committedInventory.MuzzleComponents[0].IsAssembledIntoTower, Is.False); + Assert.That(committedInventory.BearingComponents[0].IsAssembledIntoTower, Is.False); + Assert.That(committedInventory.BaseComponents[0].IsAssembledIntoTower, Is.False); + } + + private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory) + { + _originalPlayerInventory = GameEntry.PlayerInventory; + _inventoryObject = new GameObject("TestPlayerInventory"); + PlayerInventoryComponent inventoryComponent = _inventoryObject.AddComponent(); + SetStaticPlayerInventory(inventoryComponent); + inventoryComponent.ReplaceInventorySnapshot(inventory); + return inventoryComponent; + } + + private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent) + { + FieldInfo backingField = typeof(GameEntry).GetField( + "k__BackingField", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(null, inventoryComponent); + } + + private static void InjectAssemblyTables( + PlayerInventoryComponent inventoryComponent, + IDataTable muzzleTable, + IDataTable bearingTable, + IDataTable baseTable) + { + FieldInfo serviceField = typeof(PlayerInventoryComponent).GetField( + "_towerAssemblyService", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(serviceField, Is.Not.Null); + object assemblyService = serviceField.GetValue(inventoryComponent); + Assert.That(assemblyService, Is.Not.Null); + + SetPrivateField(assemblyService, "_drMuzzleComp", muzzleTable); + SetPrivateField(assemblyService, "_drBearingComp", bearingTable); + SetPrivateField(assemblyService, "_drBaseComp", baseTable); + } + + 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); + field.SetValue(instance, value); + } + + private static DRMuzzleComp CreateMuzzleRow() + { + DRMuzzleComp row = new DRMuzzleComp(); + Assert.That(row.ParseDataRow("\t1\t\t测试枪口\t[10,20,30,40,50]\t3\t0.15\tNormalBullet\t\t[Fire]", null), Is.True); + return row; + } + + private static DRBearingComp CreateBearingRow() + { + DRBearingComp row = new DRBearingComp(); + Assert.That(row.ParseDataRow("\t1\t\t测试轴承\t[1,2,3,4,5]\t0.5\t[10,20,30,40,50]\t1\t\t[Ice]", null), Is.True); + return row; + } + + private static DRBaseComp CreateBaseRow() + { + DRBaseComp row = new DRBaseComp(); + Assert.That(row.ParseDataRow("\t1\t\t测试底座\t[2,4,6,8,10]\t-0.25\tFire\t\t[Fire]", 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) + { + 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) + { + return _rowsById.ContainsKey(id); + } + + public bool HasDataRow(Predicate condition) + { + return GetDataRow(condition) != null; + } + + public TRow GetDataRow(int id) + { + return _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) + { + return _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 = new int[_rowsById.Count]; + _rowsById.Keys.CopyTo(ids, 0); + Array.Sort(ids); + return ids; + } + } + } +} diff --git a/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs.meta b/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs.meta new file mode 100644 index 0000000..31aad66 --- /dev/null +++ b/Assets/Tests/EditMode/PlayerInventoryTowerAssemblyServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: aa889597670a1524ebf4e952871c5414 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/ProcedureMainFlowTests.cs b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs new file mode 100644 index 0000000..4468b1c --- /dev/null +++ b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs @@ -0,0 +1,531 @@ +using System.Collections.Generic; +using System.Reflection; +using CustomComponent; +using GeometryTD.CustomComponent; +using GeometryTD.CustomEvent; +using GeometryTD.Definition; +using GeometryTD.Procedure; +using GeometryTD.UI; +using NUnit.Framework; +using UnityEngine; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class ProcedureMainFlowTests + { + private GameObject _uiRouterObject; + private GameObject _inventoryObject; + private GameObject _nodeMapFormObject; + private UIRouterComponent _originalUIRouter; + private PlayerInventoryComponent _originalPlayerInventory; + + [TearDown] + public void TearDown() + { + SetStaticUIRouter(_originalUIRouter); + SetStaticPlayerInventory(_originalPlayerInventory); + + if (_uiRouterObject != null) + { + Object.DestroyImmediate(_uiRouterObject); + _uiRouterObject = null; + } + + if (_inventoryObject != null) + { + Object.DestroyImmediate(_inventoryObject); + _inventoryObject = null; + } + + if (_nodeMapFormObject != null) + { + Object.DestroyImmediate(_nodeMapFormObject); + _nodeMapFormObject = null; + } + } + + [Test] + public void OnNodeEnter_Accepted_Event_Closes_Hub_And_Enters_NodeActive() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub); + + InvokePrivate( + procedure, + "OnNodeEnter", + null, + NodeEnterEventArgs.Create("testrun", 101, RunNodeType.Combat, 0)); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.NodeActive)); + Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0)); + } + + [Test] + public void OnNodeEnter_Ignores_Mismatched_Node_And_Leaves_Hub_Visible() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub); + + InvokePrivate( + procedure, + "OnNodeEnter", + null, + NodeEnterEventArgs.Create("testrun", 999, RunNodeType.Combat, 0)); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0)); + Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0)); + } + + [Test] + public void OnNodeComplete_Normal_Completion_Returns_To_Hub_And_Advances_Run() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + RunState runState = CreateTwoNodeRun(); + ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive); + + InvokePrivate( + procedure, + "OnNodeComplete", + null, + NodeCompleteEventArgs.Create( + "testrun", + 101, + RunNodeType.Combat, + 0, + RunNodeCompletionStatus.Completed, + true, + new BackpackInventoryData { Gold = 77 })); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1)); + Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Completed)); + Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available)); + Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(77)); + Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0)); + + NodeMapFormUseCase nodeMapUseCase = GetNodeMapUseCase(procedure); + NodeMapFormRawData rawData = nodeMapUseCase.CreateInitialModel(); + Assert.That(rawData.ProgressText, Is.EqualTo("1 / 2")); + Assert.That(rawData.CurrentNodeText, Is.EqualTo("当前节点: 事件")); + } + + [Test] + public void OnNodeComplete_Exception_Returns_To_Hub_And_Marks_Current_Node_Exception() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + RunState runState = CreateTwoNodeRun(); + ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive); + + InvokePrivate( + procedure, + "OnNodeComplete", + null, + NodeCompleteEventArgs.Create( + "testrun", + 101, + RunNodeType.Combat, + 0, + RunNodeCompletionStatus.Exception, + false, + new BackpackInventoryData { Gold = 12 })); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); + Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception)); + Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(12)); + Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0)); + } + + [Test] + public void OnNodeComplete_With_Broken_Participant_Tower_Opens_Removal_Dialog_After_Returning_To_Hub() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f)); + RunState runState = CreateTwoNodeRun(); + ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive); + BackpackInventoryData brokenSnapshot = inventoryComponent.GetInventorySnapshot(); + brokenSnapshot.MuzzleComponents[0].Endurance = 0f; + + InvokePrivate( + procedure, + "OnNodeComplete", + null, + NodeCompleteEventArgs.Create( + "testrun", + 101, + RunNodeType.Combat, + 0, + RunNodeCompletionStatus.Completed, + true, + brokenSnapshot)); + + BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot(); + DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(replacedInventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); + Assert.That(recorderRouter.NodeMapController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.MainController.OpenCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1)); + Assert.That(dialogRawData, Is.Not.Null); + Assert.That(dialogRawData.Title, Is.EqualTo("出战塔已损坏")); + } + + [Test] + public void OnNodeComplete_Boss_Run_Completed_Shows_Run_Complete_Dialog_And_Does_Not_Override_It_With_Cleanup_Dialog() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f)); + RunState runState = RunStateFactory.Create( + LevelThemeType.Plain, + inventoryComponent.GetInventorySnapshot(), + new[] + { + new RunNodeSeed { NodeId = 901, NodeType = RunNodeType.BossCombat, LinkedLevelId = 4 } + }, + "testrun"); + ProcedureMain procedure = CreateProcedureMain(runState, ProcedureMainFlowPhase.NodeActive); + BackpackInventoryData brokenSnapshot = inventoryComponent.GetInventorySnapshot(); + brokenSnapshot.MuzzleComponents[0].Endurance = 0f; + + InvokePrivate( + procedure, + "OnNodeComplete", + null, + NodeCompleteEventArgs.Create( + "testrun", + 901, + RunNodeType.BossCombat, + 0, + RunNodeCompletionStatus.Completed, + true, + brokenSnapshot)); + + BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot(); + DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.RunCompletedPendingFinish)); + Assert.That(runState.IsCompleted, Is.True); + Assert.That(replacedInventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); + Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1)); + Assert.That(dialogRawData, Is.Not.Null); + Assert.That(dialogRawData.Title, Is.EqualTo("Run Complete")); + Assert.That(dialogRawData.Message, Is.EqualTo("Boss node completed. This run is finished and will return to the main menu.")); + Assert.That(dialogRawData.OnClickConfirm, Is.Not.Null); + + dialogRawData.OnClickConfirm(null); + + Assert.That(GetReturnToMenuPending(procedure), Is.True); + } + + [Test] + public void OnNodeMapNodeEnterRequested_Ignores_Request_When_FlowPhase_Is_Not_Hub() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.NodeActive); + + InvokePrivate( + procedure, + "OnNodeMapNodeEnterRequested", + CreateNodeMapFormSender(), + NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0)); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.NodeActive)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0)); + Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0)); + Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0)); + } + + [Test] + public void OnNodeMapNodeEnterRequested_Ignores_Request_When_Target_Node_Does_Not_Match_Current_Node() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub); + + InvokePrivate( + procedure, + "OnNodeMapNodeEnterRequested", + CreateNodeMapFormSender(), + NodeMapNodeEnterRequestedEventArgs.Create("testrun", 102, RunNodeType.Event, 1)); + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(0)); + Assert.That(recorderRouter.NodeMapController.CloseCount, Is.EqualTo(0)); + Assert.That(recorderRouter.MainController.CloseCount, Is.EqualTo(0)); + } + + [Test] + public void OnNodeMapNodeEnterRequested_Blocked_Combat_With_Null_Inventory_Opens_Block_Dialog() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub); + + InvokePrivate( + procedure, + "OnNodeMapNodeEnterRequested", + CreateNodeMapFormSender(), + NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0)); + + DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(recorderRouter.DialogController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1)); + Assert.That(dialogRawData, Is.Not.Null); + Assert.That(dialogRawData.Title, Is.EqualTo("无法进入战斗")); + Assert.That(dialogRawData.Message, Is.EqualTo("当前无法读取库存快照,暂时不能进入战斗。请重新进入本轮流程后再试。")); + } + + [Test] + public void OnNodeMapNodeEnterRequested_Blocked_Combat_With_No_Valid_Participant_Tower_Opens_Block_Dialog() + { + RecorderUIRouter recorderRouter = CreateBoundUIRouter(); + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f)); + BackpackInventoryData invalidInventory = inventoryComponent.GetInventorySnapshot(); + invalidInventory.ParticipantTowerInstanceIds.Clear(); + inventoryComponent.ReplaceInventorySnapshot(invalidInventory); + + ProcedureMain procedure = CreateProcedureMain(CreateTwoNodeRun(), ProcedureMainFlowPhase.Hub); + + InvokePrivate( + procedure, + "OnNodeMapNodeEnterRequested", + CreateNodeMapFormSender(), + NodeMapNodeEnterRequestedEventArgs.Create("testrun", 101, RunNodeType.Combat, 0)); + + DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; + + Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); + Assert.That(recorderRouter.DialogController.CloseCount, Is.EqualTo(1)); + Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1)); + Assert.That(dialogRawData, Is.Not.Null); + Assert.That(dialogRawData.Title, Is.EqualTo("无法进入战斗")); + Assert.That( + dialogRawData.Message, + Is.EqualTo("参战区至少需要 1 座完整装配了枪口、轴承、底座的塔,才能进入战斗。")); + } + + private RecorderUIRouter CreateBoundUIRouter() + { + _originalUIRouter = GameEntry.UIRouter; + _uiRouterObject = new GameObject("ProcedureMainFlowTests-UIRouter"); + + UIRouterComponent uiRouter = _uiRouterObject.AddComponent(); + SetStaticUIRouter(uiRouter); + + RecorderUIRouter recorderRouter = new RecorderUIRouter(uiRouter); + recorderRouter.Bind(UIFormType.NodeMapForm); + recorderRouter.Bind(UIFormType.MainForm); + recorderRouter.Bind(UIFormType.DialogForm); + recorderRouter.Bind(UIFormType.RepoForm); + return recorderRouter; + } + + private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory) + { + _originalPlayerInventory = GameEntry.PlayerInventory; + _inventoryObject = new GameObject("ProcedureMainFlowTests-Inventory"); + PlayerInventoryComponent inventoryComponent = _inventoryObject.AddComponent(); + SetStaticPlayerInventory(inventoryComponent); + inventoryComponent.ReplaceInventorySnapshot(inventory); + return inventoryComponent; + } + + private NodeMapForm CreateNodeMapFormSender() + { + _nodeMapFormObject = new GameObject("ProcedureMainFlowTests-NodeMapForm"); + return _nodeMapFormObject.AddComponent(); + } + + private static ProcedureMain CreateProcedureMain(RunState runState, ProcedureMainFlowPhase flowPhase) + { + ProcedureMain procedure = new ProcedureMain(); + NodeMapFormUseCase nodeMapUseCase = new NodeMapFormUseCase(); + nodeMapUseCase.SetRunState(runState); + + SetPrivateField(procedure, "_currentRunState", runState); + SetPrivateField(procedure, "_nodeMapFormUseCase", nodeMapUseCase); + SetPrivateField(procedure, "_flowPhase", flowPhase); + SetPrivateField(procedure, "_isRunCompleteDialogShown", false); + SetPrivateField(procedure, "_isReturnToMenuPending", false); + return procedure; + } + + private static void SetStaticUIRouter(UIRouterComponent uiRouter) + { + FieldInfo backingField = typeof(GameEntry).GetField( + "k__BackingField", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(null, uiRouter); + } + + private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent) + { + FieldInfo backingField = typeof(GameEntry).GetField( + "k__BackingField", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(null, inventoryComponent); + } + + private static void InvokePrivate(object instance, string methodName, params object[] args) + { + MethodInfo method = instance.GetType().GetMethod( + methodName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(method, Is.Not.Null); + method.Invoke(instance, args); + } + + 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); + field.SetValue(instance, value); + } + + private static T GetPrivateField(object instance, string fieldName) + { + FieldInfo field = instance.GetType().GetField( + fieldName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(field, Is.Not.Null); + return (T)field.GetValue(instance); + } + + private static ProcedureMainFlowPhase GetFlowPhase(ProcedureMain procedure) + { + return GetPrivateField(procedure, "_flowPhase"); + } + + private static bool GetReturnToMenuPending(ProcedureMain procedure) + { + return GetPrivateField(procedure, "_isReturnToMenuPending"); + } + + private static NodeMapFormUseCase GetNodeMapUseCase(ProcedureMain procedure) + { + return GetPrivateField(procedure, "_nodeMapFormUseCase"); + } + + private static RunState CreateTwoNodeRun() + { + return RunStateFactory.Create( + LevelThemeType.Plain, + new BackpackInventoryData { Gold = 50 }, + new[] + { + new RunNodeSeed { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1, SequenceIndex = 0 }, + new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Event, SequenceIndex = 1 } + }, + "testrun"); + } + + private static BackpackInventoryData CreateParticipantInventory(float endurance) + { + BackpackInventoryData inventory = new BackpackInventoryData(); + inventory.MuzzleComponents.Add(new MuzzleCompItemData + { + InstanceId = 10001, + Name = "枪口", + Endurance = endurance + }); + inventory.BearingComponents.Add(new BearingCompItemData + { + InstanceId = 20001, + Name = "轴承", + Endurance = endurance + }); + inventory.BaseComponents.Add(new BaseCompItemData + { + InstanceId = 30001, + Name = "底座", + Endurance = endurance + }); + inventory.Towers.Add(new TowerItemData + { + InstanceId = 90001, + Name = "合法塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 30001 + }); + inventory.ParticipantTowerInstanceIds.Add(90001); + return inventory; + } + + private sealed class RecorderUIRouter + { + private readonly Dictionary _controllers = new(); + + public RecorderUIRouter(UIRouterComponent uiRouter) + { + UIRouter = uiRouter; + } + + public UIRouterComponent UIRouter { get; } + + public RecorderController NodeMapController => _controllers[UIFormType.NodeMapForm]; + + public RecorderController MainController => _controllers[UIFormType.MainForm]; + + public RecorderController DialogController => _controllers[UIFormType.DialogForm]; + + public void Bind(UIFormType formType) + { + RecorderController controller = new RecorderController(); + _controllers[formType] = controller; + + FieldInfo routeControllersField = typeof(UIRouterComponent).GetField( + "_routeControllers", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.That(routeControllersField, Is.Not.Null); + + Dictionary routeControllers = + (Dictionary)routeControllersField.GetValue(UIRouter); + Assert.That(routeControllers, Is.Not.Null); + routeControllers[formType] = controller; + } + } + + private sealed class RecorderController : IUIFormController + { + public int OpenCount { get; private set; } + + public int CloseCount { get; private set; } + + public object LastOpenedUserData { get; private set; } + + public IUIUseCase BoundUseCase { get; private set; } + + public int? OpenUI(object userData = null) + { + OpenCount++; + LastOpenedUserData = userData; + return OpenCount; + } + + public void CloseUI() + { + CloseCount++; + } + + public void BindUseCase(IUIUseCase useCase) + { + BoundUseCase = useCase; + } + } + } +} diff --git a/Assets/Tests/EditMode/ProcedureMainFlowTests.cs.meta b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs.meta new file mode 100644 index 0000000..ddaa953 --- /dev/null +++ b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3b622fd9e87fa514da333f9d8313fdd2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/ProcedureMainParticipantTowerCleanupServiceTests.cs b/Assets/Tests/EditMode/ProcedureMainParticipantTowerCleanupServiceTests.cs index 6d91d91..659efc7 100644 --- a/Assets/Tests/EditMode/ProcedureMainParticipantTowerCleanupServiceTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainParticipantTowerCleanupServiceTests.cs @@ -50,6 +50,32 @@ namespace GeometryTD.Tests.EditMode Assert.That(rawData.ConfirmText, Is.EqualTo("知道了")); } + [Test] + public void RemoveBrokenParticipantTowers_Does_Not_Remove_Towers_With_Missing_Components() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.BaseComponents.RemoveAt(0); + + ProcedureMainParticipantTowerCleanupResult result = + ProcedureMainParticipantTowerCleanupService.RemoveBrokenParticipantTowers(inventory, 4); + + Assert.That(result.HasAnyRemovedTower, Is.False); + CollectionAssert.AreEqual(new long[] { 90001, 90002 }, inventory.ParticipantTowerInstanceIds); + Assert.That(inventory.Towers[0].IsParticipatingInCombat, Is.True); + } + + [Test] + public void BuildRemovedTowerDialogRawData_Returns_Default_Message_When_No_Tower_Was_Removed() + { + DialogFormRawData rawData = + ProcedureMainParticipantTowerCleanupService.BuildRemovedTowerDialogRawData( + new ProcedureMainParticipantTowerCleanupResult()); + + Assert.That(rawData.Title, Is.EqualTo("出战塔已损坏")); + Assert.That(rawData.Message, Is.EqualTo("当前没有需要移出参战区的损坏防御塔。")); + Assert.That(rawData.ConfirmText, Is.EqualTo("知道了")); + } + private static BackpackInventoryData CreateInventory() { BackpackInventoryData inventory = new BackpackInventoryData(); diff --git a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs index 93bb711..2dc651a 100644 --- a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs @@ -90,6 +90,22 @@ namespace GeometryTD.Tests.EditMode Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10)); } + [Test] + public void TryAdvanceRun_Returns_NoChange_But_Still_Replaces_Snapshot_For_NonTerminal_Status() + { + RunState runState = CreateTwoNodeRun(); + + ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun( + runState, + RunNodeCompletionStatus.None, + new BackpackInventoryData { Gold = 999 }); + + Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange)); + Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); + Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available)); + Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(999)); + } + [Test] public void MatchesCurrentNode_Returns_True_For_Current_Node_And_Default_Wildcards() { @@ -136,6 +152,23 @@ namespace GeometryTD.Tests.EditMode Assert.That(sequenceMismatch, Is.False); } + [Test] + public void MatchesCurrentNode_Returns_False_When_Run_Has_No_Current_Node() + { + RunState completedRun = RunStateFactory.Create( + LevelThemeType.Plain, + new BackpackInventoryData { Gold = 10 }, + new RunNodeSeed[0]); + + bool match = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + completedRun, + 1, + RunNodeType.Combat, + 0); + + Assert.That(match, Is.False); + } + [Test] public void TryEnterCompletedPendingFinish_Shows_Dialog_Only_Once() { diff --git a/Assets/Tests/EditMode/TagCombatRuntimeTests.cs b/Assets/Tests/EditMode/TagCombatRuntimeTests.cs index 6c44a86..b095e61 100644 --- a/Assets/Tests/EditMode/TagCombatRuntimeTests.cs +++ b/Assets/Tests/EditMode/TagCombatRuntimeTests.cs @@ -90,6 +90,41 @@ namespace GeometryTD.Tests.EditMode Assert.That(hitContext.IsCriticalHit, Is.True); Assert.That(hitContext.FinalDamage, Is.EqualTo(281)); } + + [Test] + public void GetTagStack_Returns_Zero_For_Missing_Invalid_Or_Empty_Data() + { + Assert.That(TagEffectResolver.GetTagStack(null, TagType.Fire), Is.EqualTo(0)); + Assert.That(TagEffectResolver.GetTagStack(System.Array.Empty(), TagType.Fire), Is.EqualTo(0)); + Assert.That(TagEffectResolver.GetTagStack( + new[] + { + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 0 }, + new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 } + }, + TagType.Fire), Is.EqualTo(0)); + Assert.That(TagEffectResolver.GetTagStack( + new[] + { + new TagRuntimeData { TagType = TagType.Ice, TotalStack = 2 } + }, + TagType.None), Is.EqualTo(0)); + } + + [Test] + public void GetTagStack_Returns_First_Positive_Matching_Stack() + { + int stack = TagEffectResolver.GetTagStack( + new[] + { + null, + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 2 }, + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 4 } + }, + TagType.Fire); + + Assert.That(stack, Is.EqualTo(2)); + } } public sealed class EnemyTagStatusRuntimeTests @@ -273,6 +308,35 @@ namespace GeometryTD.Tests.EditMode Assert.That(totalDamage, Is.EqualTo(0)); Assert.That(runtime.GetMoveSpeedMultiplier(), Is.EqualTo(1f).Within(0.001f)); } + + [Test] + public void SampleInventory_TowerFireTags_Flow_From_ComponentAggregation_To_BurnDamage() + { + BackpackInventoryData inventory = InventorySeedUtility.CreateSampleInventory(); + TowerItemData tower = inventory.Towers[0]; + + Assert.That(tower.Stats, Is.Not.Null); + Assert.That(tower.Stats.Tags, Is.EqualTo(new[] { TagType.Fire })); + Assert.That(tower.Stats.TagRuntimes, Has.Length.EqualTo(1)); + Assert.That(tower.Stats.TagRuntimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(tower.Stats.TagRuntimes[0].TotalStack, Is.EqualTo(3)); + + EnemyTagStatusRuntime runtime = new EnemyTagStatusRuntime(); + int totalDamage = 0; + TagEffectResolver.ApplyAfterHit(new AttackPayload + { + BaseDamage = tower.Stats.AttackDamage[0], + AttackPropertyType = tower.Stats.AttackPropertyType, + TagRuntimes = tower.Stats.TagRuntimes + }, runtime); + + runtime.Tick(1f, damage => totalDamage += damage); + runtime.Tick(1f, damage => totalDamage += damage); + runtime.Tick(1f, damage => totalDamage += damage); + + Assert.That(runtime.HasStatus(TagType.Fire), Is.False); + Assert.That(totalDamage, Is.EqualTo(180)); + } } public sealed class EnemyStatusTagRegistryTests diff --git a/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs index a0cf837..ee83fab 100644 --- a/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs +++ b/Assets/Tests/EditMode/TowerTagAggregationServiceTests.cs @@ -49,6 +49,38 @@ namespace GeometryTD.Tests.EditMode Assert.That(tags, Is.EqualTo(new[] { TagType.Fire, TagType.Crit })); } + [Test] + public void BuildRuntimeTagsFromUniqueTags_Filters_Invalid_Values_And_Assigns_One_Stack() + { + TagRuntimeData[] runtimes = TowerTagAggregationService.BuildRuntimeTagsFromUniqueTags(new[] + { + TagType.None, + TagType.Ice, + TagType.Fire, + TagType.Ice, + (TagType)99 + }); + + Assert.That(runtimes, Has.Length.EqualTo(2)); + Assert.That(runtimes[0].TagType, Is.EqualTo(TagType.Fire)); + Assert.That(runtimes[0].TotalStack, Is.EqualTo(1)); + Assert.That(runtimes[1].TagType, Is.EqualTo(TagType.Ice)); + Assert.That(runtimes[1].TotalStack, Is.EqualTo(1)); + } + + [Test] + public void FlattenUniqueTags_Ignores_Runtime_Entries_With_NonPositive_Stack() + { + TagType[] tags = TowerTagAggregationService.FlattenUniqueTags(new[] + { + new TagRuntimeData { TagType = TagType.Fire, TotalStack = 0 }, + new TagRuntimeData { TagType = TagType.Ice, TotalStack = -1 }, + new TagRuntimeData { TagType = TagType.Crit, TotalStack = 1 } + }); + + Assert.That(tags, Is.EqualTo(new[] { TagType.Crit })); + } + [Test] public void BuildTowerTagTexts_Uses_Runtime_Stacks_For_Display() { diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index 0da49ba..abfa269 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -80,8 +80,8 @@ ### 当前剩余缺口 -- `S3` 已完成收口,不再把“三组件完整合法参战”视为 `P0-10` 的功能缺口;当前只剩文档验收口径与状态同步。 -- 品质 / Tag / 耐久不再是“只有局部实现”的状态:`S4-02 ~ S4-06` 与 `S5-01 ~ S5-04` 已落地;M1 当前真正未完全收口的只剩 `S4-07` 的三表方案命名、文档口径与少量配置映射收尾。 +- `S3` 已完成收口,不再把“三组件完整合法参战”视为 `P0-10` 的功能缺口;对应文档与状态已同步。 +- 品质 / Tag / 耐久不再是“只有局部实现”的状态:`S4-02 ~ S4-07` 与 `S5-01 ~ S5-04` 已落地;M1 主功能侧当前已无未收口项,后续重点转入测试补强与旧文档清理。 ## 阶段 S2 - 对齐节点流程 UI 的 MVP 口径 @@ -131,11 +131,11 @@ | [x] | S4-04 | 实现组件实例 Tag 的统一生成入口 | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉落、商店、初始种子、事件奖励共用同一生成结果 | | [x] | S4-05 | 实现组塔后的 Tag 汇总与展示入口 | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 组件 Tag 可汇总为塔级结果,且 UI 展示口径一致 | | [x] | S4-06 | 实现首批基础 Tag 的战斗生效 | `Assets/GameMain/Scripts/Entity/`
`Assets/GameMain/Scripts/Components/`
`Assets/GameMain/Scripts/Definition/` | 首发 6~8 个基础 Tag 至少完成一批可验证效果,且战斗入口与状态运行时结构可继续扩展 | -| [~] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`
`Assets/GameMain/Scripts/Definition/` | 表字段不是只存在而未被消费,Tag 参数可配置可解释 | +| [x] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`
`Assets/GameMain/Scripts/Definition/` | 三表职责、默认值、运行时消费链与文档口径一致,Tag 参数可配置可解释 | > 2026-03-09 更新:`InventoryRarityRuleService` 已落地;塔品质计算与组件品质归一化已统一收口,`PlayerInventoryTowerAssemblyService`、`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 已接入同一规则入口。你已确认 Unity Test Runner 中 `Assets/Tests/EditMode` 全部通过,其中包含新增的 `InventoryRarityRuleServiceTests`。 > -> 2026-03-09 更新:`S4-03` 文档口径已冻结;`TagSystemDesign.md` 已明确组件实例生成、塔级 `Stack` 汇总、四段触发阶段、`Tag.txt + TagRule` 双表方向,以及 MVP 正式首发 7 个 Tag:`Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`。`BurnSpread` 已明确后移。 +> 2026-03-09 更新:`S4-03` 文档口径已冻结;`TagSystemDesign.md` 已明确组件实例生成、塔级 `Stack` 汇总、四段触发阶段,以及 MVP 正式首发 7 个 Tag:`Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`。`BurnSpread` 已明确后移。后续 `S4-07` 已进一步把配置层固定为 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案。 > > 2026-03-09 更新:`S4-04` 已落地 `ComponentTagGenerationService` 与 `InventoryTagSourceType`;组件实例 Tag 现在统一按 `PossibleTag + Tag.txt.MinRarity + 品质预算` 生成,并只保留当前正式首发 7 个 Tag。`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 已接入该入口,样例库存的组件与塔展示 Tag 已同步为统一结果;同时新增 `ComponentTagGenerationServiceTests`。当前 CLI 下 `dotnet build GeometryTD.sln` 仍因本机缺少 Unity 引用和 `Unity.SourceGenerators*.dll` 失败,未能替代 Unity Test Runner 完成最终验证。 > @@ -145,11 +145,7 @@ > > 2026-03-10 更新:`S4-06` 已补齐首发剩余 3 个 Tag。`Shatter` 已接入命中前数值修正链,并按“目标已减速”口径增伤;`Inferno` 与 `AbsoluteZero` 已按首发方案作为 `Fire` / `Ice` 的强化 Tag 落地,分别增强 DOT 时长/伤害与减速时长/强度;对应 EditMode 测试已同步补齐。`BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 仍保持分类与占位路由,没有实际战斗效果,因此仍属于后续扩展,不计入 `S4-06` 当前完成标准。 > -> 2026-03-11 更新:`S4-07` 已进入第一阶段实现。当前已新增 `TagConfig.txt` 与 `DRTagConfig`,`ProcedurePreload` 会在加载 `TagConfig` 表后驱动 `TagDefinitionRegistry.LoadFromRows(...)`,把 `TriggerPhase`、`Description` 以及首发 7 个 Tag 的配置参数从表覆盖到运行时强类型 `TagConfig`。`ItemDescForm` 也已开始消费该配置说明;塔详情会优先使用 `TagRuntimes` 构建 `Ice x2` 这类叠层展示。因此 `S4-07` 不再是“完全未开始”,但当前仍只完成了 `TagConfig` 级别的参数映射与 UI 消费,尚未把文档中的完整 `TagRule`(如权重、生成规则等)全部收口。 -> -> 2026-03-11 更新:`S4-07` 已继续推进到第二阶段。当前 `Tag.txt -> DRTag -> TagGenerationRuleRegistry` 已形成组件 Tag 生成规则闭环,`ComponentTagGenerationService` 不再主要依赖内部 `MinRarity` 硬编码,而是统一消费 `DRTag` 提供的 `MinRarity + Weight`;`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility` 也已切回这一统一入口。现阶段的职责边界明确为:`Tag.txt` 负责基础字典与生成规则,`TagConfig.txt` 负责触发阶段、描述与效果参数。 -> -> 2026-03-11 补充验证:已通过手工扩充组件 `PossibleTag` 候选池并调整 `Weight` 验证生成权重实际生效;同时把所有 Tag 的 `MinRarity` 提升到 `Red` 后,低品质组件不再生成任何 Tag,说明 `Tag.txt` 中的 `MinRarity` 与 `Weight` 均已进入统一生成链。 +> 2026-03-11 更新:`S4-07` 已完成最终收口。当前仓库已明确采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案作为正式配置口径,其中 `Tag.txt -> DRTag -> TagGenerationRuleRegistry` 负责基础字典、生成门槛、权重与启用态,`RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry` 负责按品质的 Tag 数量预算,`TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry` 负责触发阶段、描述与首发 7 个 Tag 的效果参数。`ComponentTagGenerationService`、`ShopFormUseCase`、`EnemyDropResolver`、`InventorySeedUtility`、`ItemDescForm` 与战斗运行时均已接入对应消费链;同时已把 registry 默认值对齐到三表当前数值,避免加载顺序或回退路径重新暴露旧口径。 ### S4-01 边界结论 @@ -184,10 +180,9 @@ - `S4-06` 已完成:战斗链已支持 Tag 透传与统一结算,正式首发 7 个 Tag `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero` 均已有可验证效果。 - `Shatter` 已接入命中前数值修正链;`Inferno`、`AbsoluteZero` 已按“强化现有 `Fire` / `Ice` 状态”的首发口径落地,状态类 Tag 的内部结构继续保持注册式运行时。 - `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 已进入分类与配置骨架,但仍属于后续扩展,不计入当前 `S4` 的完成标准。 -- `S4-07` 已进入第一阶段实现。当前仓库已具备 `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> ItemDescForm` 的消费闭环,首发 7 个 Tag 的触发阶段、描述与核心参数已可由表覆盖。 -- `S4-07` 已推进到第二阶段。当前仓库同时具备 `Tag.txt -> DRTag -> TagGenerationRuleRegistry -> ComponentTagGenerationService` 与 `TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> ItemDescForm` 两条消费闭环,首发 7 个 Tag 的生成规则、触发阶段、描述与核心参数都已开始由表驱动。 -- `S4-07` 已推进到第三阶段。当前仓库新增 `RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry -> ComponentTagGenerationService`,组件 Tag 数量预算不再写死在 `ResolveRarityTagBudget(...)` 的 `switch` 中,而是按品质从表驱动;至此生成链的候选过滤、权重抽取、数量预算三段都已进入 DataTable 消费闭环。 -- `S4-07` 仍未完成最终收口。当前采用的是 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 的分层方案,而不是文档原方案里的单独 `TagRule`;更深的元数据字段与完整命名口径仍待后续决定是否继续统一。 +- `S4-07` 已完成。当前仓库已具备 `Tag.txt -> DRTag -> TagGenerationRuleRegistry -> ComponentTagGenerationService`、`RarityTagBudget.txt -> DRRarityTagBudget -> RarityTagBudgetRuleRegistry -> ComponentTagGenerationService`、`TagConfig.txt -> DRTagConfig -> TagDefinitionRegistry -> 展示 / 战斗运行时` 三条正式消费闭环。 +- 三表方案已经固定为本阶段正式口径,不再把单独的 `TagRule` 表视为当前 M1 的待决定项。 +- `S4-07` 当前通过标准是“表字段被实际消费、参数可解释、运行时与文档口径一致”;这一标准已满足。 ### S4-06 当前代码状态 @@ -205,18 +200,18 @@ ### S4 后续执行计划 -1. 继续推进 `S4-07`:在现有 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层方案基础上,决定是否补成文档中的完整 `TagRule`,或明确当前三表就是本阶段的等价收口方案。 -2. `S4-07` 完成标准仍以“表字段被实际消费、参数可解释、运行时与文档口径一致”为准;当前已完成首发 7 个 Tag 的生成规则、数量预算、参数和说明配置化,后续重点转向是否还要继续配置化更深层的元数据字段。 -3. `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续留在后续扩展阶段,不因为 `S4-06` 完成而提前进入当前迭代。 +1. `S4-07` 已完成,当前不再继续围绕三表命名与职责拆分新任务。 +2. 后续如果要继续配置化更深层 Tag 元数据,应作为新的增强项单独立项,不回挂到当前 M1。 +3. `BurnSpread`、`IgniteBurst`、`FreezeMask`、`Pierce`、`Overpenetrate` 继续留在后续扩展阶段,不因为 `S4-07` 完成而提前进入当前迭代。 ## 阶段 S5 - 收口耐久规则 -| 状态 | ID | 任务 | 交付物路径 | 验收标准 | -|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|----------------| +| 状态 | ID | 任务 | 交付物路径 | 验收标准 | +|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|------------------------------| | [x] | S5-01 | 先确认 M1 是否保留完整耐久闭环 | `docs/CodeX-TODO.md`
`docs/TODO.md` | 已明确按“最小耐久闭环”推进,不扩展维修/折价/自动销毁 | -| [x] | S5-02 | 若保留,定义耐久对属性/出战资格的影响方式 | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/Entity/` | 已统一为“归零失效、不可参战、非归零不衰减” | -| [x] | S5-03 | 实现耐久扣减后的实际生效 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/Entity/` | 已按“每场固定扣 1、仅扣本场参战塔”接进主链 | -| [x] | S5-04 | 实现 `0` 耐久失效闭环 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 已实现失效不可参战、仓库提示、节点结束后自动踢出参战区 | +| [x] | S5-02 | 若保留,定义耐久对属性/出战资格的影响方式 | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/Entity/` | 已统一为“归零失效、不可参战、非归零不衰减” | +| [x] | S5-03 | 实现耐久扣减后的实际生效 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/Entity/` | 已按“每场固定扣 1、仅扣本场参战塔”接进主链 | +| [x] | S5-04 | 实现 `0` 耐久失效闭环 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 已实现失效不可参战、仓库提示、节点结束后自动踢出参战区 | ### S5 规划结论 @@ -264,26 +259,36 @@ ## 阶段 S6 - 回归与文档收尾 -| 状态 | ID | 任务 | 交付物路径 | 验收标准 | -|-----|-------|-------------------------|---------------------------------------|--------------------------| -| [ ] | S6-01 | 补主链路回归测试 | `Assets/GameMain/Tests/` | Run 推进、节点回流、Boss 完成可回归验证 | -| [ ] | S6-02 | 补规则侧测试 | `Assets/GameMain/Tests/` | 合法性、品质、Tag、耐久关键公式可验证 | -| [x] | S6-03 | 回写 `docs/TODO.md` 的真实状态 | `docs/TODO.md` | 文档状态与仓库现状一致 | -| [~] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`
`Assets/GameMain/Scripts/` | `CodeX-TODO` 与 `TODO` 已完成同步;其余旧设计文档仍待后续收尾 | +| 状态 | ID | 任务 | 交付物路径 | 验收标准 | +|-----|-------|-------------------------|---------------------------------------|------------------------------------------------------------------------------------------| +| [x] | S6-01 | 补主链路回归测试 | `Assets/Tests/EditMode/` | Run 推进、节点回流、Boss 完成可回归验证 | +| [x] | S6-02 | 补规则侧测试 | `Assets/Tests/EditMode/` | 合法性、品质、Tag、耐久关键公式可验证 | +| [x] | S6-03 | 回写 `docs/TODO.md` 的真实状态 | `docs/TODO.md` | 文档状态与仓库现状一致 | +| [x] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`
`Assets/GameMain/Scripts/` | `CodeX-TODO`、`TODO`、`TagSystemDesign`、`MVP-Scope`、`GameDesign` 已完成主要口径同步;其余文档仅保留后续阶段设计描述 | + +> 2026-03-11 更新:`S6` 的自动化测试实际落点已统一到 `Assets/Tests/EditMode`,不再使用 `Assets/GameMain/Tests/` 作为当前仓库测试路径口径。 +> +> 2026-03-11 更新:本轮已补 `CombatSettlementServiceTests` 的结算金币、奖励选择失败分支、奖励库存写入与提交幂等测试;同时补 `ProcedureMainServicesTests` 的非终态快照替换行为、`ProcedureMainParticipantTowerCleanupServiceTests` 的“缺件不移出/空结果提示”分支,以及 `TowerTagAggregationServiceTests`、`TagCombatRuntimeTests` 的基础工具回归。你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。 +> +> 2026-03-11 更新:`S6-01` 已完成主链路流程编排回归收口。新增 `ProcedureMainFlowTests`,覆盖 `OnNodeEnter` 的 Hub -> NodeActive 切换、`OnNodeComplete` 的正常推进 / 异常回流 / Boss 完成、损坏塔清理不覆盖 Run 完成弹窗,以及 `OnNodeMapNodeEnterRequested` 的主要忽略条件与战斗入口阻塞对话框分支;你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。 +> +> 2026-03-11 更新:`S6-02` 已完成规则侧测试收口。除已有的出战合法性、品质、组件 Tag 生成、塔 Tag 聚合与 Tag 战斗运行时测试外,本轮继续补了 `CombatSettlementServiceTests` 的失败结算掉落金保留、奖励组件并包闭环,以及 `PlayerInventoryTowerAssemblyServiceTests` 的“三组件组塔 -> 品质 / 属性数组 / TagRuntimes / Tags”装配回归;你已确认本轮在 Unity Test Runner 中重新实跑后全部通过。 +> +> 2026-03-11 更新:`S6-04` 已完成。`MVP-Scope.md`、`GameDesign.md`、`TODO.md`、`CodeX-TODO.md` 与 `TagSystemDesign.md` 现已统一回写到当前真实实现口径:战斗奖励改为“敌人概率掉落 + 结算金币 + 满血额外 3 选 1”,商店改为“当前 M1 只保留基础购买”,并移除了“开局二选三组件组两塔”“M1 不做耐久 / 红色品质”“组件/配件混写”等已过期描述。当前 docs 下未再发现会误导 M1 开发的主冲突项,剩余内容仅保留明确标注为后续阶段的设计预案。 ## 推荐执行顺序 1. `S1 ~ S3` 已完成,不再作为当前主阻塞项。 -2. `S4` 只剩 `S4-07` 的最终收口;当前优先任务是确认 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 是否就是本阶段正式方案,并把命名与文档解释统一。 +2. `S4` 已完成,当前不再把三表方案作为 M1 主阻塞项。 3. `S5` 已完成,当前无需继续按旧耐久设计拆任务。 4. `S6` 转入“测试补强 + 旧文档清理”收尾;重点不再是补主功能,而是把现状固化并避免后续继续按旧口径推进。 -5. `S6` 完成后,再决定是否把维修、自动销毁、耐久折价、完整 `TagRule` 元数据等长期设计拆成新的增强阶段。 +5. `S6` 完成后,再决定是否把维修、自动销毁、耐久折价、更多 Tag 元数据配置化等长期设计拆成新的增强阶段。 ## 本周建议开工顺序 -1. 先完成 `S4-07` 的最终口径收口,把三表方案、命名和文档解释彻底统一 -2. 再补 `S6-01 ~ S6-02` 的主链路 / 规则回归测试,把当前 M1 口径固化下来 -3. 最后继续处理 `S6-04`,清理 `MVP-Scope`、`GameDesign` 等仍保留旧范围描述的文档 +1. 先补 `S6-01 ~ S6-02` 的主链路 / 规则回归测试,把当前 M1 口径固化下来 +2. 再继续处理 `S6-04`,清理 `MVP-Scope`、`GameDesign` 等仍保留旧范围描述的文档 +3. 最后再决定是否开启新的增强阶段,而不是回头重开 `S4-07` ## 备注 diff --git a/docs/GameDesign.md b/docs/GameDesign.md index 3d8e07b..0d1cc28 100644 --- a/docs/GameDesign.md +++ b/docs/GameDesign.md @@ -4,40 +4,40 @@ 塔防肉鸽 ## 游戏主循环 -1. 进入游戏选择初始的塔防配件,组装初始的防御塔开始游戏 +1. 进入游戏,使用当前样例库存中的组件组装初始防御塔并开始游戏 2. 在关卡中玩家选择不同的节点推进关卡: - - 战斗节点:布置防御塔抵御敌人进攻并收集防御塔组件与配件 + - 战斗节点:布置防御塔抵御敌人进攻并收集防御塔组件 - 事件节点:随机事件(不含战斗) - - 商店节点:购买/出售防御塔配件/组件,购买关卡内道具 -3. 节点后调整:玩家可使用组件和配件自由组装防御塔以抵御更强的敌人进攻 + - 商店节点:购买防御塔组件 +3. 节点后调整:玩家可使用三组件自由组装防御塔以抵御更强的敌人进攻 4. 开始新一轮关卡 ## 具体说明 ### 一、战前准备: -1. 选择 3 种组件任意两个以完成 2 个防御塔的组装 +1. 当前 M1 以样例库存和仓库内三组件组装链为准,不再要求独立的“开局二选三组件并组出两座塔”前置流程 ### 二、节点: -1. 游戏内有多种主题地图,每种主题地图视为一大关,不同的主题地图敌人掉落以及关卡奖励的组件/配件属性偏好不同,玩家在完成一大关后可自由选择下一大关的主题地图,关卡难度由玩家攻略的顺序决定,与主题地图本身没有太大关系 -2. 主题地图示例: +1. 当前 M1 只实现单一固定 Run:每个大关固定 10 个节点,最后一个节点固定为 Boss 战斗节点;多主题地图与大关间选择保留到后续阶段 +2. 主题地图示例(后续阶段): - 火山:高温(战斗节点中会随机触发火山喷发在地图上生成岩浆格) - 对于防御塔:部分组件在该高温下能发挥更强/更弱性能,若岩浆生成在防御塔上将会进一步强化高温对组件性能的影响 - 对于敌人:敌人多具有火焰抗性,部分敌人能在岩浆格上更快的行走 - 山地:地势起伏(地图格子有额外的高度条件,悬崖格子) - 对于防御塔:不同高度的攻击会有攻击范围变化,高打低范围加强,低打高范围缩小 - 对于敌人:不同高度会有移动速度的差异,高往低走移速加快,低往高走移速降低,陆地敌人被击退到悬崖格子立即死亡 -3. 每个大关有 10 个节点,最后一个节点固定为 Boss 战斗格,完成胜利波次后就会结束该大关,选择并进入下一大关 +3. 当前每个大关有 10 个固定节点,完成 Boss 节点后进入正式结束态并返回主菜单,不在 M1 内继续展开下一大关选择 #### 1. 战斗节点 1. 玩家携带一定数量的防御塔进入关卡,在关卡内规定的位置布置防御塔,具体的游戏逻辑与一般的塔防游戏类似: - 关卡开始有一些资源用于布置防御塔,击杀敌人获取资源来布置或升级防御塔。 - - 敌人会选择最短路径由出怪口向玩家基地前进(部分防御塔配件可以阻挡道路,以延长怪物的行进路线) -2. 击杀敌人除了获取关卡内使用的资源外,还有概率掉落防御塔组件/配件,每个小关卡以及大关卡结束后也会奖励防御塔组件/配件以及一些金币用于商店购买 + - 敌人会选择最短路径由出怪口向玩家基地前进;道路阻挡与更复杂的路径改写机制保留到后续阶段 +2. 击杀敌人除了获取关卡内使用的资源外,还有概率掉落防御塔组件;每个小关卡结束后也会奖励组件与金币用于后续节点 3. 关卡内设定一个胜利波次,当玩家存活的波次达到后会根据基地生命产生不同的事件: - = 100% :获得额外 30% 的金币,以及额外 1 次组件 3 选 1 - >= 80% :获得额外 10% 的金币 - >= 50% :无加成 - < 50%:损失携带防御塔耐久 -4. 胜利后可以选择直接结束该关卡,也可以继续关卡以获得更多的组件/配件掉落,胜利波次后的波次将会迅速提升敌人的强度,同时较小幅度地提高爆率 +4. “胜利后继续挑战”保留到后续阶段,当前 M1 以正常结算回流节点地图为准 #### 2. 事件节点 玩家经历一些有选项的随机事件,获取额外的奖励/惩罚。 @@ -45,19 +45,19 @@ 示例: 1. 玩家花费 100 金币赌马,有两个选择:(1) 30% 赢,赢了获得 250 金币。(2) 70% 赢,赢了获得 150 金币 2. 玩家提供 2 个防御塔组件,获得 1 个不低于原来品质的防御塔组件 -3. 消耗 2 个随机防御塔所有配件的耐久获得 150 金币 +3. 耐久换金币事件保留到后续阶段;当前 M1 只保留最小耐久闭环 #### 3. 商店节点 -1. 玩家购买防御塔组件/配件,高品质的商品要价更高,售出价格为购买价格的一半,出售防御塔时价格为其所有配件价格之和再加10%,出售配件时耐久度会影响配件的价格 -2. 玩家购买道具,道具包含各种各样的效果,比如提高关卡内初始金币,提高敌人掉落金币倍率…… +1. 当前 M1 只实现组件商店的基础购买;商店内出售、刷新、复杂定价、卖塔加成与耐久折价保留到后续阶段 +2. 道具系统保留到后续阶段 ### 三、节点后的调整 -1. 组装防御塔:每个防御塔都必须有枪口(攻击组件)、轴承(旋转组件)、底座(功能组件)三个组件,防御塔的品质由组件决定。品质不仅决定了防御塔的基础属性,还可以为防御塔提供配件槽位(配件也能为防御塔提供额外的属性)以及 Tag 数量与品质 +1. 组装防御塔:每个防御塔都必须有枪口(攻击组件)、轴承(旋转组件)、底座(功能组件)三个组件,防御塔的品质由组件决定。当前 M1 已实现三组件完整合法参战、品质统一计算、组件实例 Tag 生成、塔级 Tag 汇总,以及首发 7 个 Tag 的战斗效果 - 组件功能(某些组件还有全局性的属性倍率,比如高伤害穿透攻击的枪口会绑一个 0.5x攻击速度 的属性倍率来约束防御塔性能): - 枪口(攻击组件):决定攻击伤害,攻击方式(普通子弹、范围伤害、穿透激光……) - 轴承(旋转组件):决定枪口转速(某些攻击方式只有对准敌人后才能进行攻击),攻击范围 - 底座(功能组件):决定攻击频率,攻击属性(火焰、毒素、冰……) - - 品质计算:每个组件提供一定的品质权重(白:1,绿:2,蓝:3,紫:4,红:5),比如三个配件是 2 绿 1 白,那么防御塔的品质是 (2+2+1)/3=1.67,四舍五入后为 2 也就是绿色品质。 - - 各品质的配件槽:白/绿:0,蓝:1,紫:2,红:3。 - - 组件 Tag:不同的品质随机出现的 Tag 等级与数量不同,例如白色组件有 50% 不出 Tag,40% 出 1 级 Tag,10% 出 2 级 Tag -2. 拆解防御塔:组件/配件有耐久属性,组件/配件的耐久与其提供的属性相关,50% 耐久时就只能提供原来一半的属性值,当耐久为 0 时将会直接销毁 \ No newline at end of file + - 品质计算:每个组件提供一定的品质权重(白:1,绿:2,蓝:3,紫:4,红:5),比如三个组件是 2 绿 1 白,那么防御塔的品质是 (2+2+1)/3=1.67,四舍五入后为 2 也就是绿色品质。 + - 各品质的配件槽与更深的配件系统保留到后续阶段 + - 组件 Tag 当前正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表方案:`Tag.txt` 负责基础字典、生成门槛、权重与启用态,`RarityTagBudget.txt` 负责按品质的 Tag 数量预算,`TagConfig.txt` 负责触发阶段、描述与效果参数 +2. 拆解与耐久:当前 M1 只保留最小耐久闭环,即战斗后按参战塔真实扣减、`0` 耐久失效并拦截参战 / 战斗入口;连续属性衰减、自动销毁与维修系统保留到后续阶段 diff --git a/docs/MVP-Scope.md b/docs/MVP-Scope.md index 577a28c..0c69d6e 100644 --- a/docs/MVP-Scope.md +++ b/docs/MVP-Scope.md @@ -49,12 +49,12 @@ 战斗奖励: -* 战斗结束提供 3选1 组件奖励 -* 固定金币奖励 +* 敌人可概率掉落组件与金币 +* 战斗结算提供金币奖励 +* 基地满血通关时额外提供 1 次组件 3选1 奖励 不做: -* 随机掉落 * 多路径 * 地图机制 * 精细敌人AI @@ -86,13 +86,13 @@ 实现: -* 显示3个随机组件 -* 可刷新一次 -* 可购买 -* 可出售组件 +* 显示4个随机组件 +* 可购买组件 不做: +* 商店内出售组件 +* 商店刷新 * 动态定价 * 经济平衡 * 道具系统 @@ -109,20 +109,19 @@ * 塔槽位 * 拖拽组装 * 组件替换 -* 实时属性预览 +* 基础属性与 Tag 展示 组件结构(精简): * 枪口 * 轴承 * 底座 -* 基础Tag系统(只保留 6~8 个) +* 基础 Tag 系统(首发 7 个:`Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero`) 不做: -* 耐久系统 -* 稀有品质红色 -* 高级Tag联动 +* 完整维修 / 折价 / 自动销毁系统 +* 高级 Tag 联动 * 复杂触发矩阵 --- @@ -152,8 +151,8 @@ * 总组件数量 ≤ 20 * Tag数量 ≤ 8 -* 品质等级:白 / 绿 / 蓝 / 紫(不做红) -* 每塔最大槽位:2 +* 品质等级:白 / 绿 / 蓝 / 紫 / 红 +* 仅要求三组件主结构,不扩展更深的配件槽系统 --- @@ -184,7 +183,7 @@ 2. 节点之间流程无阻塞 3. 组件可组装并生效 4. 战斗可胜可负 -5. 商店可购买/出售 +5. 商店可购买 6. 不出现流程死锁 7. 无严重崩溃或逻辑错误 @@ -197,4 +196,4 @@ * 不评估留存 * 不评估爽感强度 * 不评估平衡性 -* 不评估变现能力 \ No newline at end of file +* 不评估变现能力 diff --git a/docs/TODO.md b/docs/TODO.md index d38f019..bdf1b0a 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -12,9 +12,9 @@ ## M1 当前口径(2026-03-11) - 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode` 的主流程 Run 闭环,可以从主菜单进入游戏,完成固定 10 节点流程,并在 Boss 结算后进入正式结束态并回到主菜单。 -- `NodeMapForm` 已满足 MVP 所需的节点流程界面;M1 当前真正未完全收口的只剩 `P0-11` 对应的三表方案命名、文档解释与少量配置映射收尾。 +- `NodeMapForm` 已满足 MVP 所需的节点流程界面;M1 主功能侧已完成收口,后续重点转入测试补强与旧文档清理。 - `P0-10` 的“三组件完整合法参战”主链已完成:当前参战分配、战斗入口最终校验、失败原因与拦截提示都已接入统一合法性判断入口,现阶段不再把它视为 M1 功能缺口。 -- `P0-11` 已不再只是“局部展示字段”:当前品质计算、Tag 生成、塔级 Tag 汇总、首发 7 个 Tag 的战斗效果、以及 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表驱动链路都已存在;剩余缺口主要是 `S4-07` 的最终文档口径与少量配置收口,而不是主功能缺失。 +- `P0-11` 已完成收口:当前品质计算、Tag 生成、塔级 Tag 汇总、首发 7 个 Tag 的战斗效果、以及 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三表驱动链路都已落地,代码默认值、运行时消费链与文档口径现已统一。 - `P0-12` 的“最小耐久闭环”主链已落地:当前已实现战斗后按参战塔真实扣减耐久、`0` 耐久失效并拦截参战/战斗入口、仓库详情损坏提示,以及节点结束后自动将损坏塔移出参战区并弹窗说明。 ## 里程碑 M1(P0)- 最小可玩闭环 @@ -22,17 +22,17 @@ | 状态 | ID | 任务 | 交付物路径 | 验收标准 | |-----|-------|-------------------------------------|----------------------------------------------------------------------------------------|--------------------------------------------------------------------------| | [x] | P0-01 | 冻结 MVP 范围(只保留:战斗节点/事件节点/商店节点/节点后组装) | `docs/MVP-Scope.md` | 明确“做/不做”清单,团队评审通过 | -| [x] | P0-02 | 补齐数据表:组件、配件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 | +| [x] | P0-02 | 补齐数据表:组件、敌人、波次、节点、事件、商店商品 | `Assets/GameMain/DataTables/*.txt` | 游戏启动可无报错加载全部新增表 | | [x] | P0-03 | 新增/完善 DataRow 解析类 | `Assets/GameMain/Scripts/DataTable/*.cs` | 每个新增表都有对应 DR 类并成功解析 | | [x] | P0-04 | 建立单局 Run 状态模型(金币、生命、库存、当前节点) | `Assets/GameMain/Scripts/Procedure/` | 已有 `RunState` / 推进模型 / 固定序列 / EditMode 测试,并已接入 `ProcedureMain` 主流程闭环 | | [x] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss) | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/UI/Game/` | 已有固定 10 节点序列、当前节点限制、Boss 终点链路与节点流程入口;MVP 不额外要求节点地图选择 UI 或表现层打磨 | | [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 战斗 / 事件 / 商店已统一接入节点进入、完成、异常回流;Boss 完成后会进入正式结束态并返回主菜单 | | [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`
`Assets/GameMain/Scripts/Scene/` | 可完整打一场并得到胜利/失败结果 | | [x] | P0-08 | 胜利波次与结算规则(100/80/50/<50) | `Assets/GameMain/Scripts/Procedure/` | 结算奖励与惩罚严格匹配设计文档 | -| [x] | P0-09 | 敌人掉落与关卡奖励(组件/配件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 | +| [x] | P0-09 | 敌人掉落与关卡奖励(组件/金币) | `Assets/GameMain/Scripts/Entity/` | 战斗结束能发放掉落并写入库存 | | [x] | P0-10 | 节点后组装:枪口/轴承/底座三组件约束 | `Assets/GameMain/Scripts/Entity/`
`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 已完成“三组件完整合法参战”的统一校验链、战斗入口最终拦截与失败原因反馈;不再作为当前 M1 功能缺口 | -| [~] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/Entity/` | 当前已完成品质统一、Tag 生成/汇总/展示与首发 7 个 Tag 的战斗生效;剩余工作主要是三表方案的最终收口与文档同步 | -| [x] | P0-12 | 组件/配件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 | +| [x] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`
`Assets/GameMain/Scripts/Entity/` | 已完成品质统一、Tag 生成/汇总/展示、首发 7 个 Tag 的战斗生效,以及三表方案的正式收口与文档同步 | +| [x] | P0-12 | 组件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`
`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 | ## 里程碑 M2(P1)- 核心深度 @@ -40,13 +40,13 @@ |-----|-------|-------------------------------|-------------------------------------------------------------------------------------------|--------------------| | [ ] | P1-01 | 事件节点系统(选项、概率、奖励/惩罚执行器) | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 可配置并执行至少 10 个事件 | | [ ] | P1-02 | 落地设计中的 3 个示例事件(赌马/组件交换/耐久换金币) | `Assets/GameMain/DataTables/*.txt` | 三个事件可在局内完整触发与结算 | -| [ ] | P1-03 | 商店节点:购买/出售组件与配件 | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 买卖后库存与金币实时正确更新 | +| [ ] | P1-03 | 商店节点:购买/出售组件 | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 买卖后库存与金币实时正确更新 | | [ ] | P1-04 | 商店定价规则:买价、半价回收、卖塔+10%、耐久折价 | `Assets/GameMain/Scripts/Utility/`
`Assets/GameMain/Scripts/Entity/` | 各种交易结果符合公式 | | [ ] | P1-05 | 道具系统(影响初始金币、掉金倍率等) | `Assets/GameMain/Scripts/Entity/`
`Assets/GameMain/DataTables/*.txt` | 至少 5 个道具可叠加生效 | | [ ] | P1-06 | 战斗后“继续挑战”机制(高强度高爆率) | `Assets/GameMain/Scripts/Procedure/` | 选择继续后敌人强度明显提升且爆率提升 | | [ ] | P1-07 | 火山主题规则(高温、岩浆格、抗火敌人) | `Assets/GameMain/Scripts/Scene/`
`Assets/GameMain/Scripts/Entity/` | 岩浆效果可视且会改变战斗结果 | | [ ] | P1-08 | 山地主题规则(高度、悬崖、位移致死) | `Assets/GameMain/Scripts/Scene/`
`Assets/GameMain/Scripts/Entity/` | 高低地形影响攻防与移速,悬崖击退生效 | -| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件/配件) | `Assets/GameMain/DataTables/*.txt`
`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 | +| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件) | `Assets/GameMain/DataTables/*.txt`
`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 | | [ ] | P1-10 | 大关完成后可选下一主题地图 | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 通关后至少可在 2 个主题间选择 | ## 里程碑 M3(P2)- 打磨与上线准备 @@ -58,14 +58,14 @@ | [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 | | [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`
`Assets/GameMain/Scripts/Procedure/` | 重启游戏后进度一致恢复 | | [ ] | P2-05 | 平衡性首轮调参(敌人曲线、经济曲线、掉落曲线) | `Assets/GameMain/DataTables/*.txt` | 3 局平均时长与胜率落在预期区间 | -| [~] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/` 下新增 `Editor`/`Runtime` 测试 | 已有 `Assets/Tests/EditMode` 覆盖 `RunState`、`NodeCompleteEventArgs`、`ProcedureMain` 关键服务;更广的公式与流程回归仍待补齐 | +| [~] | P2-06 | 最低限度自动化测试(公式与关键流程) | `Assets/Tests/` 下的 `EditMode`/`PlayMode` 测试 | 已有 `Assets/Tests/EditMode` 覆盖 `RunState`、`NodeCompleteEventArgs`、`ProcedureMain` 关键服务,以及 `CombatSettlement`、损坏塔清理、品质、Tag、耐久等关键规则;更广的流程编排回归与 PlayMode 覆盖仍待补齐 | | [ ] | P2-07 | 性能与稳定性检查(长局、内存、异常日志) | `docs/PerformanceReport.md` | 连续游玩 30 分钟无阻断性问题 | ## 本周建议开工顺序 -1. 先完成 `P0-11` 的三表口径收口,把 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 的实际消费链、命名和文档解释彻底对齐 -2. 再补 `S6` 侧的主链路 / 规则回归测试,把当前 M1 口径固化下来 -3. 最后继续同步 `MVP-Scope.md`、`GameDesign.md` 等仍保留旧范围描述的文档,避免后续继续按旧耐久 / Tag 口径推进 +1. 先补 `S6` 侧的主链路 / 规则回归测试,把当前 M1 口径固化下来 +2. 再继续同步 `MVP-Scope.md`、`GameDesign.md` 等仍保留旧范围描述的文档,避免后续继续按旧耐久 / Tag 口径推进 +3. 最后再决定是否把更多 Tag 元数据配置化、维修系统等长期设计拆成新的增强阶段 ## 设计优化 Backlog(新增) @@ -83,7 +83,7 @@ ## 小目标 1. CombatNodeComponent:战斗节点的逻辑补全 - 完整塔防流程:初始硬币,敌人掉落硬币数量、基地生命与失败设计 - - 敌人随机掉落局外资源(金币、组件/配件) + - 敌人随机掉落局外资源(金币、组件) - 无限波次下敌人血量、资源爆率的增加 - 基地血量满足一定条件的额外奖励 2. ShopNodeComponent:商店节点的逻辑补全 diff --git a/docs/TagSystemDesign.md b/docs/TagSystemDesign.md index d06e010..94b5764 100644 --- a/docs/TagSystemDesign.md +++ b/docs/TagSystemDesign.md @@ -1,26 +1,25 @@ # Tag System Design -最后更新:2026-03-09 +最后更新:2026-03-11 -> 目标:梳理 GeometryTD 的 Tag 系统设计问题,明确组件随机生成规则、战斗生效方式,以及后续实现顺序。 +> 目标:固定 GeometryTD 当前 M1 的 Tag 系统正式口径,明确三表配置职责、实际消费链,以及后续增强边界。 ## 1. 当前现状 -当前仓库已经有 Tag 相关的基础数据结构,但还没有形成完整系统: +当前仓库已经形成 M1 所需的 Tag 最小闭环: - `TagType` 已定义 12 个 Tag,见 `Assets/GameMain/Scripts/Definition/Enum/TagType.cs` -- `Tag.txt` 已定义 Tag 名称与 `MinRarity` +- `Tag.txt` 已定义 Tag 名称、`MinRarity`、`Weight`、`IsImplemented` - `MuzzleComp.txt`、`BearingComp.txt`、`BaseComp.txt` 已定义 `PossibleTag` -- 组件实例、塔实例、UI 展示链路都已有 `Tags` 字段 +- `RarityTagBudget.txt` 已定义按品质的 Tag 数量预算 +- `TagConfig.txt` 已定义触发阶段、描述与首发 Tag 的核心参数 +- 组件实例、塔实例、UI 展示链路都已接入统一的生成、汇总与说明消费口径 -当前问题也很明确: +当前 `S4-07` 收尾只处理三件事: -- 组件实例现在更接近“直接复制 `PossibleTag` 全量候选”,不是真正的随机生成 -- `Tag.txt` 只有最低品质限制,不足以描述权重、互斥、数量、效果参数 -- 塔组装阶段当前只是简单做并集去重,无法表达重复 Tag 是叠层还是浪费 -- 战斗链路里,子弹命中只传 `damage + AttackPropertyType`,还没有 Tag 结算入口 - -因此,当前 Tag 更像“静态标签字段”,而不是“有来源、有汇总、有战斗行为”的系统。 +- 明确三表就是 M1 的正式配置方案,不再保留 `TagRule` 作为当前未决项 +- 统一默认值、运行时加载与文档描述,避免回退到旧口径 +- 把未首发 Tag 保持为占位扩展,而不是误判为当前必须实现的功能缺口 ## 2. 设计目标 @@ -99,15 +98,10 @@ Tag 系统需要同时满足四个目标: ### 4.2 运行时结构 -后续运行时模型固定从当前单纯的 `TagType[]` 演进为两层结构: +当前 M1 运行时结构固定为两层: -```csharp -public sealed class TagEntryData -{ - public TagType TagType { get; set; } - public int Stack { get; set; } -} -``` +- 组件实例继续保存 `TagType[]` +- 塔实例保存汇总后的 `TagRuntimeData[]` ```csharp public sealed class TagRuntimeData @@ -119,7 +113,7 @@ public sealed class TagRuntimeData 职责划分: -- 组件实例保存 `TagEntryData[]` +- 组件实例保存 `TagType[]` - 塔实例保存汇总后的 `TagRuntimeData[]` - UI 展示可以继续只展示 Tag 名称,但底层不再丢失层数信息 @@ -160,23 +154,15 @@ Tag 随机应发生在“组件实例创建时”,而不是组塔时。 每个品质都保留独立的 Tag 数量预算,并由 `RarityTagBudget.txt` 驱动,而不是按概率硬编码在代码里。 -推荐默认值: +当前正式默认值: | 品质 | 推荐数量 | |------|----------| | White | `0~1` | -| Green | `1` | -| Blue | `1~2` | -| Purple | `2` | -| Red | `2~3` | - -当前若严格遵循 MVP 范围,也可以先只做到: - -- `White`: `0~1` -- `Green`: `1` -- `Blue`: `1~2` -- `Purple`: `2` -- `Red`: 暂不开放或仅内部保留 +| Green | `0~2` | +| Blue | `1~3` | +| Purple | `1~3` | +| Red | `2~4` | ### 5.4 随机规则 @@ -210,7 +196,7 @@ Tag 随机必须可复现。 固定流程: -1. 收集三组件的 `TagEntryData[]` +1. 收集三组件的 `TagType[]` 2. 按 `TagType` 分组 3. 累加 `Stack` 4. 输出塔级别的 `TagRuntimeData[]` @@ -444,14 +430,13 @@ Tag 触发阶段固定拆成四段: 而 `BurnSpread`、`Pierce`、`Overpenetrate`、`FreezeMask`、`IgniteBurst` 都作为后续扩展,不属于当前 `S4` 首发范围。 -## 11. 后续文档与代码动作 +## 11. 当前收口结论 -1. 先基于本设计回写 `docs/CodeX-TODO.md` 的 `S4-03` 边界 -2. 在 `S4-07` 中补齐并消费 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三层表结构 -3. 新增 `ComponentTagGenerationService`,先收口实例生成 -4. 新增 `TowerTagAggregationService`,替换当前简单并集逻辑 -5. 评估 `AttackPayload` 是否并入现有 `BulletData`,还是单独引入 -6. 第一批只落固定 7 个基础 Tag,避免超出 `MVP-Scope` +1. `S4-07` 已确认采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 三层表结构作为正式方案 +2. `ComponentTagGenerationService` 已收口组件实例生成,不再直接复制 `PossibleTag` +3. `TowerTagAggregationService` 已收口塔级汇总,重复 Tag 会转为 `TagRuntimeData.TotalStack` +4. `AttackPayload + HitContext + TagEffectResolver` 已成为当前战斗内的统一 Tag 挂载点 +5. 首发只实现固定 7 个基础 Tag,避免超出 `MVP-Scope` ## 12. 默认决策 @@ -460,10 +445,11 @@ Tag 触发阶段固定拆成四段: - Tag 在组件实例创建时随机,不在组塔时随机 - `PossibleTag` 是候选池,不是最终实例值 - 塔级 Tag 汇总保留 `Stack` -- 配置层采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层结构 +- 配置层正式采用 `Tag.txt + RarityTagBudget.txt + TagConfig.txt` 分层结构 - 第一批战斗效果优先做状态类与数值修正类,不优先做穿透 / 爆炸 / 传播 - MVP 正式首发集合固定为 `Fire`、`Ice`、`Crit`、`Execution`、`Shatter`、`Inferno`、`AbsoluteZero` - `BurnSpread` 明确后移,不作为当前首发集合成员 +- `TagGroup` 当前只作为分类展示元数据,不进入运行时生成、汇总或战斗规则链 # 总体结构 diff --git a/tmp.txt b/tmp.txt new file mode 100644 index 0000000..c7596cf --- /dev/null +++ b/tmp.txt @@ -0,0 +1,16 @@ +BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection (0.004s) +--- +System.NullReferenceException : Object reference not set to an instance of an object +--- +at GeometryTD.CustomComponent.CombatRunResourceStore.FireCoinChangedEvent (System.Int32 deltaCoin) [0x00011] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\GameMain\Scripts\CustomComponent\CombatNode\CombatScheduler\CombatRunResourceStore.cs:259 + at GeometryTD.CustomComponent.CombatRunResourceStore.AddEnemyDefeatedReward (System.Int32 gainedCoin, System.Int32 gainedGold) [0x00057] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\GameMain\Scripts\CustomComponent\CombatNode\CombatScheduler\CombatRunResourceStore.cs:187 + at GeometryTD.Tests.EditMode.CombatSettlementServiceTests.BuildSettlementContext_On_Loss_Keeps_DropGold_Without_SettlementBonus_Or_RewardSelection () [0x00036] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\Tests\EditMode\CombatSettlementServiceTests.cs:166 + at (wrapper managed-to-native) System.Reflection.RuntimeMethodInfo.InternalInvoke(System.Reflection.RuntimeMethodInfo,object,object[],System.Exception&) + at System.Reflection.RuntimeMethodInfo.Invoke (System.Object obj, System.Reflection.BindingFlags invokeAttr, System.Reflection.Binder binder, System.Object[] parameters, System.Globalization.CultureInfo culture) [0x0006a] in <1071a2cb0cb3433aae80a793c277a048>:0 + +CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory (0.003s) +--- +Expected: True + But was: False +--- +at GeometryTD.Tests.EditMode.CombatSettlementServiceTests.CommitSettlementInventory_Merges_Selected_Reward_Component_Into_PlayerInventory () [0x000a3] in D:\Learn\GameLearn\UnityProjects\GeometryTD\Assets\Tests\EditMode\CombatSettlementServiceTests.cs:257 \ No newline at end of file