S6 回归与文档收尾

This commit is contained in:
SepComet 2026-03-11 21:19:53 +08:00
parent ac1c91c1b4
commit 09ebe6e3f3
20 changed files with 1498 additions and 136 deletions

View File

@ -38,10 +38,10 @@ namespace GeometryTD.Definition
return new Dictionary<RarityType, RarityTagBudgetRule>
{
[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)
};
}

View File

@ -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),

View File

@ -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

View File

@ -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(
"<CurrentBaseHp>k__BackingField",
BindingFlags.Instance | BindingFlags.NonPublic);
Assert.That(backingField, Is.Not.Null);
backingField.SetValue(resourceStore, currentBaseHp);
}
private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory)
{
_originalPlayerInventory = GameEntry.PlayerInventory;

View File

@ -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()
{

View File

@ -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<DRMuzzleComp>(CreateMuzzleRow()),
new FakeDataTable<DRBearingComp>(CreateBearingRow()),
new FakeDataTable<DRBaseComp>(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<DRMuzzleComp>(CreateMuzzleRow()),
new FakeDataTable<DRBearingComp>(CreateBearingRow()),
new FakeDataTable<DRBaseComp>(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<PlayerInventoryComponent>();
SetStaticPlayerInventory(inventoryComponent);
inventoryComponent.ReplaceInventorySnapshot(inventory);
return inventoryComponent;
}
private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent)
{
FieldInfo backingField = typeof(GameEntry).GetField(
"<PlayerInventory>k__BackingField",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(backingField, Is.Not.Null);
backingField.SetValue(null, inventoryComponent);
}
private static void InjectAssemblyTables(
PlayerInventoryComponent inventoryComponent,
IDataTable<DRMuzzleComp> muzzleTable,
IDataTable<DRBearingComp> bearingTable,
IDataTable<DRBaseComp> 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<TRow> : IDataTable<TRow> where TRow : class, IDataRow
{
private readonly Dictionary<int, TRow> _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<TRow> condition)
{
return GetDataRow(condition) != null;
}
public TRow GetDataRow(int id)
{
return _rowsById.TryGetValue(id, out TRow row) ? row : null;
}
public TRow GetDataRow(Predicate<TRow> 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<TRow> condition)
{
List<TRow> results = new();
GetDataRows(condition, results);
return results.ToArray();
}
public void GetDataRows(Predicate<TRow> condition, List<TRow> 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<TRow> comparison)
{
List<TRow> results = new();
GetDataRows(comparison, results);
return results.ToArray();
}
public void GetDataRows(Comparison<TRow> comparison, List<TRow> results)
{
results?.Clear();
if (results == null)
{
return;
}
results.AddRange(_rowsById.Values);
if (comparison != null)
{
results.Sort(comparison);
}
}
public TRow[] GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison)
{
List<TRow> results = new();
GetDataRows(condition, comparison, results);
return results.ToArray();
}
public void GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison, List<TRow> results)
{
GetDataRows(condition, results);
if (results != null && comparison != null)
{
results.Sort(comparison);
}
}
public TRow[] GetAllDataRows()
{
List<TRow> results = new();
GetAllDataRows(results);
return results.ToArray();
}
public void GetAllDataRows(List<TRow> 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<TRow> 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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: aa889597670a1524ebf4e952871c5414
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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<UIRouterComponent>();
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<PlayerInventoryComponent>();
SetStaticPlayerInventory(inventoryComponent);
inventoryComponent.ReplaceInventorySnapshot(inventory);
return inventoryComponent;
}
private NodeMapForm CreateNodeMapFormSender()
{
_nodeMapFormObject = new GameObject("ProcedureMainFlowTests-NodeMapForm");
return _nodeMapFormObject.AddComponent<NodeMapForm>();
}
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(
"<UIRouter>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(
"<PlayerInventory>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<T>(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<ProcedureMainFlowPhase>(procedure, "_flowPhase");
}
private static bool GetReturnToMenuPending(ProcedureMain procedure)
{
return GetPrivateField<bool>(procedure, "_isReturnToMenuPending");
}
private static NodeMapFormUseCase GetNodeMapUseCase(ProcedureMain procedure)
{
return GetPrivateField<NodeMapFormUseCase>(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<UIFormType, RecorderController> _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<UIFormType, IUIFormController> routeControllers =
(Dictionary<UIFormType, IUIFormController>)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;
}
}
}
}

View File

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3b622fd9e87fa514da333f9d8313fdd2
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@ -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();

View File

@ -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()
{

View File

@ -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<TagRuntimeData>(), 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

View File

@ -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()
{

View File

@ -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/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 掉落、商店、初始种子、事件奖励共用同一生成结果 |
| [x] | S4-05 | 实现组塔后的 Tag 汇总与展示入口 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 组件 Tag 可汇总为塔级结果,且 UI 展示口径一致 |
| [x] | S4-06 | 实现首批基础 Tag 的战斗生效 | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/Scripts/Components/`<br>`Assets/GameMain/Scripts/Definition/` | 首发 6~8 个基础 Tag 至少完成一批可验证效果,且战斗入口与状态运行时结构可继续扩展 |
| [~] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`<br>`Assets/GameMain/Scripts/Definition/` | 表字段不是只存在而未被消费Tag 参数可配置可解释 |
| [x] | S4-07 | 补齐 Tag 规则与数据表的映射关系 | `Assets/GameMain/Scripts/DataTable/`<br>`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,14 +200,14 @@
### 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 | 任务 | 交付物路径 | 验收标准 |
|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|----------------|
|-----|-------|-----------------------|-------------------------------------------------------------------------------------------------|------------------------------|
| [x] | S5-01 | 先确认 M1 是否保留完整耐久闭环 | `docs/CodeX-TODO.md`<br>`docs/TODO.md` | 已明确按“最小耐久闭环”推进,不扩展维修/折价/自动销毁 |
| [x] | S5-02 | 若保留,定义耐久对属性/出战资格的影响方式 | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 已统一为“归零失效、不可参战、非归零不衰减” |
| [x] | S5-03 | 实现耐久扣减后的实际生效 | `Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/Entity/` | 已按“每场固定扣 1、仅扣本场参战塔”接进主链 |
@ -265,25 +260,35 @@
## 阶段 S6 - 回归与文档收尾
| 状态 | ID | 任务 | 交付物路径 | 验收标准 |
|-----|-------|-------------------------|---------------------------------------|--------------------------|
| [ ] | S6-01 | 补主链路回归测试 | `Assets/GameMain/Tests/` | Run 推进、节点回流、Boss 完成可回归验证 |
| [ ] | S6-02 | 补规则侧测试 | `Assets/GameMain/Tests/` | 合法性、品质、Tag、耐久关键公式可验证 |
|-----|-------|-------------------------|---------------------------------------|------------------------------------------------------------------------------------------|
| [x] | S6-01 | 补主链路回归测试 | `Assets/Tests/EditMode/` | Run 推进、节点回流、Boss 完成可回归验证 |
| [x] | S6-02 | 补规则侧测试 | `Assets/Tests/EditMode/` | 合法性、品质、Tag、耐久关键公式可验证 |
| [x] | S6-03 | 回写 `docs/TODO.md` 的真实状态 | `docs/TODO.md` | 文档状态与仓库现状一致 |
| [~] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`<br>`Assets/GameMain/Scripts/` | `CodeX-TODO``TODO` 已完成同步;其余旧设计文档仍待后续收尾 |
| [x] | S6-04 | 清理临时描述、过期 TODO、命名偏差 | `docs/`<br>`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`
## 备注

View File

@ -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绿2345比如三个件是 2 绿 1 白,那么防御塔的品质是 (2+2+1)/3=1.67,四舍五入后为 2 也就是绿色品质。
- 各品质的配件槽:白/绿0123。
- 组件 Tag:不同的品质随机出现的 Tag 等级与数量不同,例如白色组件有 50% 不出 Tag40% 出 1 级 Tag10% 出 2 级 Tag
2. 拆解防御塔:组件/配件有耐久属性,组件/配件的耐久与其提供的属性相关50% 耐久时就只能提供原来一半的属性值,当耐久为 0 时将会直接销毁
- 品质计算每个组件提供一定的品质权重1绿2345比如三个件是 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` 耐久失效并拦截参战 / 战斗入口;连续属性衰减、自动销毁与维修系统保留到后续阶段

View File

@ -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. 无严重崩溃或逻辑错误

View File

@ -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` 耐久失效并拦截参战/战斗入口、仓库详情损坏提示,以及节点结束后自动将损坏塔移出参战区并弹窗说明。
## 里程碑 M1P0- 最小可玩闭环
@ -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/`<br>`Assets/GameMain/Scripts/UI/Game/` | 已有固定 10 节点序列、当前节点限制、Boss 终点链路与节点流程入口MVP 不额外要求节点地图选择 UI 或表现层打磨 |
| [x] | P0-06 | 实现节点选择与跳转流程(战斗/事件/商店) | `Assets/GameMain/Scripts/Procedure/` | 战斗 / 事件 / 商店已统一接入节点进入、完成、异常回流Boss 完成后会进入正式结束态并返回主菜单 |
| [x] | P0-07 | 战斗节点基础玩法:放置塔、出怪、基地扣血、胜负判定 | `Assets/GameMain/Scripts/Entity/`<br>`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/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 已完成“三组件完整合法参战”的统一校验链、战斗入口最终拦截与失败原因反馈;不再作为当前 M1 功能缺口 |
| [~] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 当前已完成品质统一、Tag 生成/汇总/展示与首发 7 个 Tag 的战斗生效;剩余工作主要是三表方案的最终收口与文档同步 |
| [x] | P0-12 | 组件/配件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 |
| [x] | P0-11 | 品质 / Tag 规则统一入口(白绿蓝紫红) | `Assets/GameMain/Scripts/Definition/`<br>`Assets/GameMain/Scripts/Entity/` | 已完成品质统一、Tag 生成/汇总/展示、首发 7 个 Tag 的战斗生效,以及三表方案的正式收口与文档同步 |
| [x] | P0-12 | 组件耐久最小闭环 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/`<br>`Assets/GameMain/Scripts/UI/` | 已实现战斗后真实扣减、`0` 耐久失效拦截、仓库损坏提示与节点结束后的自动移出参战区;自动销毁/维修系统保留到后续阶段 |
## 里程碑 M2P1- 核心深度
@ -40,13 +40,13 @@
|-----|-------|-------------------------------|-------------------------------------------------------------------------------------------|--------------------|
| [ ] | P1-01 | 事件节点系统(选项、概率、奖励/惩罚执行器) | `Assets/GameMain/Scripts/Procedure/`<br>`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/`<br>`Assets/GameMain/Scripts/Entity/` | 各种交易结果符合公式 |
| [ ] | P1-05 | 道具系统(影响初始金币、掉金倍率等) | `Assets/GameMain/Scripts/Entity/`<br>`Assets/GameMain/DataTables/*.txt` | 至少 5 个道具可叠加生效 |
| [ ] | P1-06 | 战斗后“继续挑战”机制(高强度高爆率) | `Assets/GameMain/Scripts/Procedure/` | 选择继续后敌人强度明显提升且爆率提升 |
| [ ] | P1-07 | 火山主题规则(高温、岩浆格、抗火敌人) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 岩浆效果可视且会改变战斗结果 |
| [ ] | P1-08 | 山地主题规则(高度、悬崖、位移致死) | `Assets/GameMain/Scripts/Scene/`<br>`Assets/GameMain/Scripts/Entity/` | 高低地形影响攻防与移速,悬崖击退生效 |
| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件/配件 | `Assets/GameMain/DataTables/*.txt`<br>`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 |
| [ ] | P1-09 | 主题地图掉落偏好(按主题偏置组件) | `Assets/GameMain/DataTables/*.txt`<br>`Assets/GameMain/Scripts/Entity/` | 不同主题统计掉落分布显著不同 |
| [ ] | P1-10 | 大关完成后可选下一主题地图 | `Assets/GameMain/Scripts/Procedure/`<br>`Assets/GameMain/Scripts/UI/Templates/GameScene/` | 通关后至少可在 2 个主题间选择 |
## 里程碑 M3P2- 打磨与上线准备
@ -58,14 +58,14 @@
| [ ] | P2-03 | 组装/拆解交互优化(预览属性变化) | `Assets/GameMain/Scripts/UI/Templates/GameScene/` | 改装前后差异可视化展示 |
| [ ] | P2-04 | 存档与读档(局外货币、库存、解锁进度) | `Assets/GameMain/Scripts/Utility/`<br>`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商店节点的逻辑补全

View File

@ -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` 当前只作为分类展示元数据,不进入运行时生成、汇总或战斗规则链
# 总体结构

16
tmp.txt Normal file
View File

@ -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