diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs index 4345918..6214604 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs @@ -106,7 +106,7 @@ namespace GeometryTD.CustomComponent _commandModel.AddGold(gainGold); } - public bool TryAddParticipantTower(long towerInstanceId, int maxCount = 4) + public ParticipantTowerAssignResult TryAddParticipantTower(long towerInstanceId, int maxCount = 4) { EnsureInitialized(); return _towerRosterService.TryAddParticipantTower(towerInstanceId, maxCount); diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs index 3a5eba8..2948af7 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs @@ -16,7 +16,7 @@ namespace GeometryTD.CustomComponent _maxParticipantTowerCount = Mathf.Max(1, maxParticipantTowerCount); } - public bool TryAddParticipantTower(long towerInstanceId, int maxCount) + public ParticipantTowerAssignResult TryAddParticipantTower(long towerInstanceId, int maxCount) { int resolvedMaxCount = Mathf.Max(1, maxCount); resolvedMaxCount = Mathf.Min(resolvedMaxCount, _maxParticipantTowerCount); diff --git a/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs new file mode 100644 index 0000000..0a8c3cf --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs @@ -0,0 +1,188 @@ +using System.Collections.Generic; + +namespace GeometryTD.Definition +{ + public enum CombatParticipantTowerValidationFailureReason + { + None = 0, + TowerMissing = 1, + MissingMuzzleComponent = 2, + MissingBearingComponent = 3, + MissingBaseComponent = 4 + } + + public sealed class CombatParticipantTowerValidationResult + { + public long TowerInstanceId { get; set; } + + public bool IsValid => FailureReason == CombatParticipantTowerValidationFailureReason.None; + + public CombatParticipantTowerValidationFailureReason FailureReason { get; set; } + + public TowerItemData Tower { get; set; } + } + + public sealed class CombatParticipantTowerValidationSummary + { + public IReadOnlyList ValidTowers { get; set; } = System.Array.Empty(); + + public IReadOnlyList InvalidResults { get; set; } = + System.Array.Empty(); + + public bool HasAnyValidParticipantTower => ValidTowers != null && ValidTowers.Count > 0; + } + + public static class CombatParticipantTowerValidationService + { + public static CombatParticipantTowerValidationResult ValidateTower( + BackpackInventoryData inventory, + long towerInstanceId) + { + if (!TryGetTowerById(inventory, towerInstanceId, out TowerItemData tower)) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = towerInstanceId, + FailureReason = CombatParticipantTowerValidationFailureReason.TowerMissing + }; + } + + return ValidateTower(inventory, tower); + } + + public static CombatParticipantTowerValidationResult ValidateTower( + BackpackInventoryData inventory, + TowerItemData tower) + { + if (tower == null || tower.InstanceId <= 0) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower != null ? tower.InstanceId : 0, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.TowerMissing + }; + } + + if (!HasComponent(inventory?.MuzzleComponents, tower.MuzzleComponentInstanceId)) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingMuzzleComponent + }; + } + + if (!HasComponent(inventory?.BearingComponents, tower.BearingComponentInstanceId)) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingBearingComponent + }; + } + + if (!HasComponent(inventory?.BaseComponents, tower.BaseComponentInstanceId)) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingBaseComponent + }; + } + + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.None + }; + } + + public static CombatParticipantTowerValidationSummary ValidateParticipantTowers(BackpackInventoryData inventory) + { + List validTowers = new List(); + List invalidResults = + new List(); + HashSet processedTowerIds = new HashSet(); + + if (inventory?.ParticipantTowerInstanceIds == null || inventory.ParticipantTowerInstanceIds.Count <= 0) + { + return new CombatParticipantTowerValidationSummary + { + ValidTowers = validTowers, + InvalidResults = invalidResults + }; + } + + for (int i = 0; i < inventory.ParticipantTowerInstanceIds.Count; i++) + { + long towerInstanceId = inventory.ParticipantTowerInstanceIds[i]; + if (towerInstanceId <= 0 || !processedTowerIds.Add(towerInstanceId)) + { + continue; + } + + CombatParticipantTowerValidationResult result = ValidateTower(inventory, towerInstanceId); + if (result.IsValid) + { + validTowers.Add(result.Tower); + } + else + { + invalidResults.Add(result); + } + } + + return new CombatParticipantTowerValidationSummary + { + ValidTowers = validTowers, + InvalidResults = invalidResults + }; + } + + private static bool TryGetTowerById(BackpackInventoryData inventory, long towerInstanceId, out TowerItemData tower) + { + tower = null; + if (inventory?.Towers == null || towerInstanceId <= 0) + { + return false; + } + + for (int i = 0; i < inventory.Towers.Count; i++) + { + TowerItemData candidate = inventory.Towers[i]; + if (candidate != null && candidate.InstanceId == towerInstanceId) + { + tower = candidate; + return true; + } + } + + return false; + } + + private static bool HasComponent(IReadOnlyList components, long componentInstanceId) + where TComponent : TowerCompItemData + { + if (components == null || componentInstanceId <= 0) + { + return false; + } + + for (int i = 0; i < components.Count; i++) + { + TComponent component = components[i]; + if (component != null && component.InstanceId == componentInstanceId) + { + return true; + } + } + + return false; + } + } +} diff --git a/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs.meta b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs.meta new file mode 100644 index 0000000..3d58a84 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 3a8f51ad44094d59af6618736cd6a0d3 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs b/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs new file mode 100644 index 0000000..91de081 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs @@ -0,0 +1,22 @@ +namespace GeometryTD.Definition +{ + public enum ParticipantTowerAssignFailureReason + { + None = 0, + TowerMissing = 1, + InvalidTower = 2, + AlreadyAssigned = 3, + ParticipantAreaFull = 4 + } + + public sealed class ParticipantTowerAssignResult + { + public long TowerInstanceId { get; set; } + + public bool IsSuccess => FailureReason == ParticipantTowerAssignFailureReason.None; + + public ParticipantTowerAssignFailureReason FailureReason { get; set; } + + public CombatParticipantTowerValidationFailureReason ValidationFailureReason { get; set; } + } +} diff --git a/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs.meta b/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs.meta new file mode 100644 index 0000000..bc6e844 --- /dev/null +++ b/Assets/GameMain/Scripts/Definition/ParticipantTowerAssignResult.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 00c667d942b94e23a4933fda5c5eaf8e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs index b846abd..fe269bb 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs @@ -75,6 +75,26 @@ namespace GeometryTD.Procedure } } + public static class ProcedureMainNodeEventGuardService + { + public static bool MatchesCurrentNode( + RunState runState, + int nodeId, + RunNodeType nodeType, + int sequenceIndex) + { + RunNodeState currentNode = runState?.CurrentNode; + if (currentNode == null) + { + return false; + } + + return (nodeId <= 0 || nodeId == currentNode.NodeId) && + (nodeType == RunNodeType.None || nodeType == currentNode.NodeType) && + (sequenceIndex < 0 || sequenceIndex == currentNode.SequenceIndex); + } + } + public class ProcedureMain : ProcedureBase { public override bool UseNativeDialog => false; @@ -177,19 +197,21 @@ namespace GeometryTD.Procedure } RunNodeState currentNode = _currentRunState?.CurrentNode; - if (currentNode != null && - ((args.NodeId > 0 && args.NodeId != currentNode.NodeId) || - (args.NodeType != RunNodeType.None && args.NodeType != currentNode.NodeType) || - (args.SequenceIndex >= 0 && args.SequenceIndex != currentNode.SequenceIndex))) + if (!ProcedureMainNodeEventGuardService.MatchesCurrentNode( + _currentRunState, + args.NodeId, + args.NodeType, + args.SequenceIndex)) { Log.Warning( "ProcedureMain.OnNodeEnter() node mismatch. EventNodeId={0}, EventNodeType={1}, EventSequenceIndex={2}; CurrentNodeId={3}, CurrentNodeType={4}, CurrentSequenceIndex={5}.", args.NodeId, args.NodeType, args.SequenceIndex, - currentNode.NodeId, - currentNode.NodeType, - currentNode.SequenceIndex); + currentNode?.NodeId ?? 0, + currentNode?.NodeType ?? RunNodeType.None, + currentNode?.SequenceIndex ?? -1); + return; } Log.Info( @@ -220,6 +242,24 @@ namespace GeometryTD.Procedure return; } + RunNodeState currentNode = _currentRunState?.CurrentNode; + if (!ProcedureMainNodeEventGuardService.MatchesCurrentNode( + _currentRunState, + args.NodeId, + args.NodeType, + args.SequenceIndex)) + { + Log.Warning( + "ProcedureMain.OnNodeComplete() node mismatch. EventNodeId={0}, EventNodeType={1}, EventSequenceIndex={2}; CurrentNodeId={3}, CurrentNodeType={4}, CurrentSequenceIndex={5}.", + args.NodeId, + args.NodeType, + args.SequenceIndex, + currentNode?.NodeId ?? 0, + currentNode?.NodeType ?? RunNodeType.None, + currentNode?.SequenceIndex ?? -1); + return; + } + Log.Info( "ProcedureMain.OnNodeComplete() accepted. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}, CompletionStatus={4}, CombatWon={5}.", string.IsNullOrWhiteSpace(args.RunId) ? _currentRunState?.RunId : args.RunId, diff --git a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs index c515806..3288874 100644 --- a/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs +++ b/Assets/GameMain/Scripts/UI/Game/Controller/RepoFormController.cs @@ -261,8 +261,18 @@ namespace GeometryTD.UI return; } - if (!_useCase.TryAddParticipantTower(args.TowerItemId)) + ParticipantTowerAssignResult result = _useCase.TryAddParticipantTower(args.TowerItemId); + if (result == null || !result.IsSuccess) { + if (result != null) + { + Log.Warning( + "RepoFormController denied participant assignment. TowerId={0}, FailureReason={1}, ValidationFailureReason={2}.", + result.TowerInstanceId, + result.FailureReason, + result.ValidationFailureReason); + } + return; } diff --git a/Assets/GameMain/Scripts/UI/Game/UseCase/RepoFormUseCase.cs b/Assets/GameMain/Scripts/UI/Game/UseCase/RepoFormUseCase.cs index e40b742..bfb14d2 100644 --- a/Assets/GameMain/Scripts/UI/Game/UseCase/RepoFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/Game/UseCase/RepoFormUseCase.cs @@ -33,7 +33,7 @@ namespace GeometryTD.UI out _); } - public bool TryAddParticipantTower(long towerItemId) + public ParticipantTowerAssignResult TryAddParticipantTower(long towerItemId) { if (GameEntry.PlayerInventory == null) { diff --git a/Assets/GameMain/Scripts/Utility/InventoryParticipantUtility.cs b/Assets/GameMain/Scripts/Utility/InventoryParticipantUtility.cs index acff244..19f9dea 100644 --- a/Assets/GameMain/Scripts/Utility/InventoryParticipantUtility.cs +++ b/Assets/GameMain/Scripts/Utility/InventoryParticipantUtility.cs @@ -6,35 +6,75 @@ namespace GeometryTD.CustomUtility { public static class InventoryParticipantUtility { - public static bool TryAddParticipantTower(BackpackInventoryData inventory, long towerInstanceId, int maxCount) + public static ParticipantTowerAssignResult TryAddParticipantTower( + BackpackInventoryData inventory, + long towerInstanceId, + int maxCount) { if (inventory == null || towerInstanceId <= 0) { - return false; + return new ParticipantTowerAssignResult + { + TowerInstanceId = towerInstanceId, + FailureReason = ParticipantTowerAssignFailureReason.TowerMissing, + ValidationFailureReason = CombatParticipantTowerValidationFailureReason.None + }; } int resolvedMaxCount = Mathf.Max(1, maxCount); NormalizeParticipantState(inventory, resolvedMaxCount); - if (!TryGetTowerById(inventory, towerInstanceId, out TowerItemData tower)) + CombatParticipantTowerValidationResult validationResult = + CombatParticipantTowerValidationService.ValidateTower(inventory, towerInstanceId); + if (!validationResult.IsValid) { - return false; + return new ParticipantTowerAssignResult + { + TowerInstanceId = towerInstanceId, + FailureReason = validationResult.FailureReason == CombatParticipantTowerValidationFailureReason.TowerMissing + ? ParticipantTowerAssignFailureReason.TowerMissing + : ParticipantTowerAssignFailureReason.InvalidTower, + ValidationFailureReason = validationResult.FailureReason + }; } inventory.ParticipantTowerInstanceIds ??= new List(); if (inventory.ParticipantTowerInstanceIds.Contains(towerInstanceId)) { - tower.IsParticipatingInCombat = true; - return false; + if (validationResult.Tower != null) + { + validationResult.Tower.IsParticipatingInCombat = true; + } + + return new ParticipantTowerAssignResult + { + TowerInstanceId = towerInstanceId, + FailureReason = ParticipantTowerAssignFailureReason.AlreadyAssigned, + ValidationFailureReason = CombatParticipantTowerValidationFailureReason.None + }; } if (inventory.ParticipantTowerInstanceIds.Count >= resolvedMaxCount) { - return false; + return new ParticipantTowerAssignResult + { + TowerInstanceId = towerInstanceId, + FailureReason = ParticipantTowerAssignFailureReason.ParticipantAreaFull, + ValidationFailureReason = CombatParticipantTowerValidationFailureReason.None + }; } inventory.ParticipantTowerInstanceIds.Add(towerInstanceId); - tower.IsParticipatingInCombat = true; - return true; + if (validationResult.Tower != null) + { + validationResult.Tower.IsParticipatingInCombat = true; + } + + return new ParticipantTowerAssignResult + { + TowerInstanceId = towerInstanceId, + FailureReason = ParticipantTowerAssignFailureReason.None, + ValidationFailureReason = CombatParticipantTowerValidationFailureReason.None + }; } public static bool TryRemoveParticipantTower(BackpackInventoryData inventory, long towerInstanceId, int maxCount) diff --git a/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs new file mode 100644 index 0000000..b3f9714 --- /dev/null +++ b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs @@ -0,0 +1,199 @@ +using System.Linq; +using GeometryTD.Definition; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class CombatParticipantTowerValidationServiceTests + { + [Test] + public void ValidateTower_Returns_Valid_When_Tower_And_Three_Components_Exist() + { + BackpackInventoryData inventory = CreateInventory(); + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, 90001); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.TowerInstanceId, Is.EqualTo(90001)); + Assert.That(result.FailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.None)); + Assert.That(result.Tower, Is.Not.Null); + } + + [Test] + public void ValidateTower_Returns_TowerMissing_When_Tower_Does_Not_Exist() + { + BackpackInventoryData inventory = CreateInventory(); + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, 12345); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.TowerInstanceId, Is.EqualTo(12345)); + Assert.That(result.FailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.TowerMissing)); + } + + [Test] + public void ValidateTower_Returns_MissingMuzzle_When_Muzzle_Is_Not_Resolvable() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.Towers[0].MuzzleComponentInstanceId = 99901; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingMuzzleComponent)); + } + + [Test] + public void ValidateTower_Returns_MissingBearing_When_Bearing_Is_Not_Resolvable() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.Towers[0].BearingComponentInstanceId = 99902; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBearingComponent)); + } + + [Test] + public void ValidateTower_Returns_MissingBase_When_Base_Is_Not_Resolvable() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.Towers[0].BaseComponentInstanceId = 99903; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); + } + + [Test] + public void ValidateParticipantTowers_Splits_Valid_And_Invalid_Towers() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.Towers.Add(new TowerItemData + { + InstanceId = 90002, + Name = "非法塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 99903, + Stats = new TowerStatsData() + }); + inventory.ParticipantTowerInstanceIds.Add(90002); + + CombatParticipantTowerValidationSummary summary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(inventory); + + Assert.That(summary.HasAnyValidParticipantTower, Is.True); + Assert.That(summary.ValidTowers.Count, Is.EqualTo(1)); + Assert.That(summary.ValidTowers[0].InstanceId, Is.EqualTo(90001)); + Assert.That(summary.InvalidResults.Count, Is.EqualTo(1)); + Assert.That(summary.InvalidResults[0].TowerInstanceId, Is.EqualTo(90002)); + Assert.That(summary.InvalidResults[0].FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); + } + + [Test] + public void ValidateParticipantTowers_Treats_Zero_Endurance_As_Valid_For_S3_01() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.MuzzleComponents[0].Endurance = 0f; + inventory.BearingComponents[0].Endurance = 0f; + inventory.BaseComponents[0].Endurance = 0f; + + CombatParticipantTowerValidationSummary summary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(inventory); + + Assert.That(summary.HasAnyValidParticipantTower, Is.True); + Assert.That(summary.ValidTowers.Count, Is.EqualTo(1)); + Assert.That(summary.InvalidResults.Count, Is.EqualTo(0)); + } + + [Test] + public void ValidateParticipantTowers_Does_Not_Require_TowerStats() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.Towers[0].Stats = null; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.True); + Assert.That(result.FailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.None)); + } + + [Test] + public void ValidateParticipantTowers_Handles_Empty_And_Duplicate_Participant_Ids() + { + BackpackInventoryData emptyInventory = CreateInventory(); + emptyInventory.ParticipantTowerInstanceIds.Clear(); + + CombatParticipantTowerValidationSummary emptySummary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(emptyInventory); + + Assert.That(emptySummary.HasAnyValidParticipantTower, Is.False); + Assert.That(emptySummary.ValidTowers.Count, Is.EqualTo(0)); + Assert.That(emptySummary.InvalidResults.Count, Is.EqualTo(0)); + + BackpackInventoryData duplicateInventory = CreateInventory(); + duplicateInventory.ParticipantTowerInstanceIds.Add(90001); + duplicateInventory.ParticipantTowerInstanceIds.Add(99999); + duplicateInventory.ParticipantTowerInstanceIds.Add(99999); + + CombatParticipantTowerValidationSummary duplicateSummary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(duplicateInventory); + + Assert.That(duplicateSummary.ValidTowers.Count, Is.EqualTo(1)); + Assert.That(duplicateSummary.InvalidResults.Count, Is.EqualTo(1)); + Assert.That(duplicateSummary.InvalidResults.Single().TowerInstanceId, Is.EqualTo(99999)); + Assert.That(duplicateSummary.InvalidResults.Single().FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.TowerMissing)); + } + + private static BackpackInventoryData CreateInventory() + { + BackpackInventoryData inventory = new BackpackInventoryData(); + + inventory.MuzzleComponents.Add(new MuzzleCompItemData + { + InstanceId = 10001, + Name = "枪口", + Endurance = 100f + }); + inventory.BearingComponents.Add(new BearingCompItemData + { + InstanceId = 20001, + Name = "轴承", + Endurance = 100f + }); + inventory.BaseComponents.Add(new BaseCompItemData + { + InstanceId = 30001, + Name = "底座", + Endurance = 100f + }); + + inventory.Towers.Add(new TowerItemData + { + InstanceId = 90001, + Name = "合法塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 30001, + Stats = new TowerStatsData() + }); + + inventory.ParticipantTowerInstanceIds.Add(90001); + return inventory; + } + } +} diff --git a/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs.meta b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs.meta new file mode 100644 index 0000000..e9c5707 --- /dev/null +++ b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 8abdddb33ee9436aad59df649f03bc57 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs b/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs new file mode 100644 index 0000000..0452887 --- /dev/null +++ b/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs @@ -0,0 +1,136 @@ +using GeometryTD.CustomUtility; +using GeometryTD.Definition; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class ParticipantTowerAssignResultTests + { + [Test] + public void TryAddParticipantTower_Returns_Success_For_Legal_Tower() + { + BackpackInventoryData inventory = CreateValidInventory(); + + ParticipantTowerAssignResult result = InventoryParticipantUtility.TryAddParticipantTower(inventory, 90001, 4); + + Assert.That(result, Is.Not.Null); + Assert.That(result.IsSuccess, Is.True); + Assert.That(result.FailureReason, Is.EqualTo(ParticipantTowerAssignFailureReason.None)); + Assert.That(result.ValidationFailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.None)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(1)); + Assert.That(inventory.ParticipantTowerInstanceIds[0], Is.EqualTo(90001)); + Assert.That(inventory.Towers[0].IsParticipatingInCombat, Is.True); + } + + [Test] + public void TryAddParticipantTower_Returns_TowerMissing_When_Tower_Is_Not_Found() + { + BackpackInventoryData inventory = CreateValidInventory(); + + ParticipantTowerAssignResult result = InventoryParticipantUtility.TryAddParticipantTower(inventory, 99999, 4); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.FailureReason, Is.EqualTo(ParticipantTowerAssignFailureReason.TowerMissing)); + Assert.That(result.ValidationFailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.TowerMissing)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); + } + + [Test] + public void TryAddParticipantTower_Returns_InvalidTower_With_Missing_Component_Reason() + { + BackpackInventoryData inventory = CreateValidInventory(); + inventory.Towers[0].BaseComponentInstanceId = 33333; + + ParticipantTowerAssignResult result = InventoryParticipantUtility.TryAddParticipantTower(inventory, 90001, 4); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.FailureReason, Is.EqualTo(ParticipantTowerAssignFailureReason.InvalidTower)); + Assert.That(result.ValidationFailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); + } + + [Test] + public void TryAddParticipantTower_Returns_AlreadyAssigned_Without_Duplicating_List() + { + BackpackInventoryData inventory = CreateValidInventory(); + inventory.ParticipantTowerInstanceIds.Add(90001); + inventory.Towers[0].IsParticipatingInCombat = true; + + ParticipantTowerAssignResult result = InventoryParticipantUtility.TryAddParticipantTower(inventory, 90001, 4); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.FailureReason, Is.EqualTo(ParticipantTowerAssignFailureReason.AlreadyAssigned)); + Assert.That(result.ValidationFailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.None)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(1)); + } + + [Test] + public void TryAddParticipantTower_Returns_ParticipantAreaFull_When_MaxCount_Reached() + { + BackpackInventoryData inventory = CreateInventoryWithFourParticipantTowers(); + + ParticipantTowerAssignResult result = InventoryParticipantUtility.TryAddParticipantTower(inventory, 90005, 4); + + Assert.That(result.IsSuccess, Is.False); + Assert.That(result.FailureReason, Is.EqualTo(ParticipantTowerAssignFailureReason.ParticipantAreaFull)); + Assert.That(result.ValidationFailureReason, Is.EqualTo(CombatParticipantTowerValidationFailureReason.None)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(4)); + Assert.That(inventory.Towers[4].IsParticipatingInCombat, Is.False); + } + + private static BackpackInventoryData CreateValidInventory() + { + BackpackInventoryData inventory = new BackpackInventoryData(); + inventory.MuzzleComponents.Add(new MuzzleCompItemData { InstanceId = 10001, Name = "枪口" }); + inventory.BearingComponents.Add(new BearingCompItemData { InstanceId = 20001, Name = "轴承" }); + inventory.BaseComponents.Add(new BaseCompItemData { InstanceId = 30001, Name = "底座" }); + inventory.Towers.Add(new TowerItemData + { + InstanceId = 90001, + Name = "合法塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 30001, + Stats = new TowerStatsData() + }); + + return inventory; + } + + private static BackpackInventoryData CreateInventoryWithFourParticipantTowers() + { + BackpackInventoryData inventory = new BackpackInventoryData(); + + for (int i = 0; i < 5; i++) + { + long towerId = 90001 + i; + long muzzleId = 10001 + i; + long bearingId = 20001 + i; + long baseId = 30001 + i; + + inventory.MuzzleComponents.Add(new MuzzleCompItemData { InstanceId = muzzleId, Name = $"枪口-{i}" }); + inventory.BearingComponents.Add(new BearingCompItemData { InstanceId = bearingId, Name = $"轴承-{i}" }); + inventory.BaseComponents.Add(new BaseCompItemData { InstanceId = baseId, Name = $"底座-{i}" }); + inventory.Towers.Add(new TowerItemData + { + InstanceId = towerId, + Name = $"塔-{i}", + MuzzleComponentInstanceId = muzzleId, + BearingComponentInstanceId = bearingId, + BaseComponentInstanceId = baseId, + Stats = new TowerStatsData(), + IsParticipatingInCombat = i < 4 + }); + + if (i < 4) + { + inventory.ParticipantTowerInstanceIds.Add(towerId); + } + } + + return inventory; + } + } +} diff --git a/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs.meta b/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs.meta new file mode 100644 index 0000000..a7aac7e --- /dev/null +++ b/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0053c263319b48eb930ef0d7794fb9dd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs index e0ce049..80d9278 100644 --- a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs @@ -89,6 +89,52 @@ namespace GeometryTD.Tests.EditMode Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10)); } + [Test] + public void MatchesCurrentNode_Returns_True_For_Current_Node_And_Default_Wildcards() + { + RunState runState = CreateTwoNodeRun(); + + bool exactMatch = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + runState, + 101, + RunNodeType.Combat, + 0); + bool wildcardMatch = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + runState, + 0, + RunNodeType.None, + -1); + + Assert.That(exactMatch, Is.True); + Assert.That(wildcardMatch, Is.True); + } + + [Test] + public void MatchesCurrentNode_Returns_False_For_Mismatched_Node_Event() + { + RunState runState = CreateTwoNodeRun(); + + bool nodeIdMismatch = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + runState, + 999, + RunNodeType.Combat, + 0); + bool nodeTypeMismatch = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + runState, + 101, + RunNodeType.Shop, + 0); + bool sequenceMismatch = ProcedureMainNodeEventGuardService.MatchesCurrentNode( + runState, + 101, + RunNodeType.Combat, + 1); + + Assert.That(nodeIdMismatch, Is.False); + Assert.That(nodeTypeMismatch, Is.False); + Assert.That(sequenceMismatch, Is.False); + } + [Test] public void TryEnterCompletedPendingFinish_Shows_Dialog_Only_Once() { diff --git a/Assets/Tests/EditMode/RunStateTests.cs b/Assets/Tests/EditMode/RunStateTests.cs index 9dc1d3f..2ce4502 100644 --- a/Assets/Tests/EditMode/RunStateTests.cs +++ b/Assets/Tests/EditMode/RunStateTests.cs @@ -107,7 +107,7 @@ namespace GeometryTD.Tests.EditMode } [Test] - public void NodeCompleteEventArgs_Clones_Inventory_And_Clears_State() + public void NodeCompleteEventArgs_Completed_Win_Clones_Inventory_And_Clears_State() { BackpackInventoryData inventory = new BackpackInventoryData { Gold = 66 }; NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( @@ -140,6 +140,54 @@ namespace GeometryTD.Tests.EditMode Assert.That(eventArgs.InventorySnapshotAfterNode, Is.Null); } + [Test] + public void NodeCompleteEventArgs_Completed_Loss_Preserves_NonWin_Semantics() + { + BackpackInventoryData inventory = new BackpackInventoryData { Gold = 12 }; + NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( + "run-2", + 8, + RunNodeType.Combat, + 4, + RunNodeCompletionStatus.Completed, + false, + inventory); + + inventory.Gold = 99; + + Assert.That(eventArgs.RunId, Is.EqualTo("run-2")); + Assert.That(eventArgs.NodeId, Is.EqualTo(8)); + Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.Combat)); + Assert.That(eventArgs.SequenceIndex, Is.EqualTo(4)); + Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Completed)); + Assert.That(eventArgs.CombatWon, Is.False); + Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(12)); + } + + [Test] + public void NodeCompleteEventArgs_Exception_Preserves_Exception_Semantics() + { + BackpackInventoryData inventory = new BackpackInventoryData { Gold = 7 }; + NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( + "run-3", + 9, + RunNodeType.BossCombat, + 5, + RunNodeCompletionStatus.Exception, + false, + inventory); + + inventory.Gold = 100; + + Assert.That(eventArgs.RunId, Is.EqualTo("run-3")); + Assert.That(eventArgs.NodeId, Is.EqualTo(9)); + Assert.That(eventArgs.NodeType, Is.EqualTo(RunNodeType.BossCombat)); + Assert.That(eventArgs.SequenceIndex, Is.EqualTo(5)); + Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Exception)); + Assert.That(eventArgs.CombatWon, Is.False); + Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(7)); + } + [Test] public void FixedRunNodeSequenceBuilder_Builds_Plain_Ten_Node_Sequence() { diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index 26c74b3..1c3d8cb 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -1,6 +1,6 @@ # CodeX TODO -最后更新:2026-03-08 +最后更新:2026-03-09 > 目标:基于当前仓库现状,为 `docs/TODO.md` 的 M1 收口补一份更可执行的补充顺序。 > 原则:先收主流程,再补硬规则,最后统一文档与验收口径。 @@ -53,6 +53,8 @@ - [x] `ProcedureMainRunFlowService` 的成功推进、异常回退、`RunCompleted`、`NoChange` 分支测试已补齐并全部通过。 - [x] `ProcedureMainRunCompletionService` 的“只弹一次结束对话框”和“只在完成态允许回菜单”测试已补齐并全部通过。 +> 2026-03-09 更新:已补 `NodeCompleteEventArgs` 三种语义测试,以及 `ProcedureMain` 当前节点匹配守卫测试;你已确认本轮在 Unity Test Runner 中重新实跑 `Assets/Tests/EditMode` 且全部通过。 + ### S1 通过标准 - [x] 从主菜单开始,一条 Run 可以稳定经历“节点进入 -> 节点完成 / 异常回流 -> Boss 完成 -> 正式结束态 -> 返回主菜单”,且过程中不会出现错误推进、重复进入、Boss 后回到普通 Hub、或 UI 残留。 @@ -61,9 +63,9 @@ ### 当前实现 -- `ProcedureMain + NodeMapForm` 已能创建固定 10 节点 Run,并驱动战斗 / 事件 / 商店三类节点的临时入口。 +- `ProcedureMain + NodeMapForm` 已能创建固定 10 节点 Run,并驱动战斗 / 事件 / 商店三类节点的主流程入口。 - `RunState`、`FixedRunNodeSequenceBuilder`、`RunStateAdvanceService` 与对应 Editor 测试已存在,说明 Run 模型、固定序列、节点推进基础能力已经落地。 -- 战斗节点已有结算、奖励选择、失败返回;事件和商店节点也会发出 `NodeEnterEventArgs / NodeCompleteEventArgs`,但主流程仍属于“临时闭环”。 +- 战斗节点已有结算、奖励选择、失败返回;事件和商店节点也会发出 `NodeEnterEventArgs / NodeCompleteEventArgs`,主流程闭环已能稳定走通。 - `P0-10 ~ P0-12` 不是纯空白: - 出战前已有“参与区至少有 1 座塔”的最低门槛; - 品质 / Tag 已在组装、商店、展示链路中分散使用; @@ -72,34 +74,51 @@ ### M1 目标状态 - M1 验收口径应以“从开始游戏到 Boss 节点结算结束,当前 Run 能稳定走完整条链”为准。 -- `NodeMapForm` 在 M1 里应被视为正式节点地图入口,而不是只靠临时面板勉强串流程。 +- `NodeMapForm` 已可视为 M1 所需的节点流程界面;M1 不额外要求节点地图选择 UI 或表现层打磨。 - 战斗 / 事件 / 商店都需要统一节点进入、完成、失败后的回流方式,避免每类节点各走一套。 - 出战合法性需要从“至少有参战塔”收口为“满足完整三组件条件才可出战”。 ### 仍未完成 -- Run 完成后的正式结束态、Boss 后收尾表现、统一的主流程回流仍未收口。 -- 节点地图表现仍缺正式状态表达、说明文案与反馈,不应把这部分误记为已完成。 +- 出战合法性仍停留在“参与区至少有 1 座塔”的最低门槛,尚未收口为“三组件完整合法参战”。 - 品质 / Tag / 耐久在代码里已有局部实现,但还没有统一规则入口,因此仍应视为未收口项,而不是 M1 已完成项。 -## 阶段 S2 - 收口节点地图表现 +## 阶段 S2 - 对齐节点流程 UI 的 MVP 口径 | 状态 | ID | 任务 | 交付物路径 | 验收标准 | |-----|-------|--------------------------------|------------------------------------|------------------------| -| [ ] | S2-01 | 把 `NodeMapForm` 从临时面板收口成正式节点地图 | `Assets/GameMain/Scripts/UI/Game/` | 节点地图不再只是临时占位面板 | -| [ ] | S2-02 | 补齐节点状态、当前节点、完成态、Boss 节点表现 | `Assets/GameMain/Scripts/UI/Game/` | 玩家能一眼读懂当前 Run 进度 | -| [ ] | S2-03 | 补齐节点说明、反馈、回流提示 | `Assets/GameMain/Scripts/UI/Game/` | 节点点击、进入、完成、失败均有明确反馈 | -| [ ] | S2-04 | 统一节点地图与主 HUD / MainForm 的职责边界 | `Assets/GameMain/Scripts/UI/Game/` | Hub UI 职责清晰,不再重复表达同类信息 | +| [x] | S2-01 | 确认 `NodeMapForm` 已满足 M1 所需的节点流程界面 | `docs/CodeX-TODO.md`
`docs/TODO.md` | 不再把“正式节点地图表现”视为 M1 阻塞项 | +| [x] | S2-02 | 明确 M1 不扩展节点地图选择 UI 与表现层打磨 | `docs/CodeX-TODO.md`
`docs/MVP-Scope.md` | 与 `MVP-Scope.md` 的范围口径一致 | ## 阶段 S3 - 收口出战合法性 | 状态 | ID | 任务 | 交付物路径 | 验收标准 | |-----|-------|------------------|--------------------------------------------------------------------------------------------------|--------------------| -| [ ] | S3-01 | 定义“合法参战塔”的最终判定口径 | `docs/CodeX-TODO.md`
`Assets/GameMain/Scripts/Definition/` | 流程层与 UI 层共用同一套判定口径 | -| [ ] | S3-02 | 收口组装区与参战区的合法性校验 | `Assets/GameMain/Scripts/UI/Game/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 不合法塔不能进入参战区 | +| [x] | S3-01 | 定义“合法参战塔”的最终判定口径 | `docs/CodeX-TODO.md`
`Assets/GameMain/Scripts/Definition/` | 流程层与 UI 层共用同一套判定口径 | +| [x] | S3-02 | 收口组装区与参战区的合法性校验 | `Assets/GameMain/Scripts/UI/Game/`
`Assets/GameMain/Scripts/CustomComponent/PlayerInventory/` | 不合法塔不能进入参战区 | | [ ] | S3-03 | 在战斗入口补齐最终出战校验 | `Assets/GameMain/Scripts/Procedure/` | 不满足完整条件时无法进入战斗节点 | | [ ] | S3-04 | 给出 UI / 日志层明确反馈 | `Assets/GameMain/Scripts/UI/Game/`
`Assets/GameMain/Scripts/Procedure/` | 玩家知道为什么不能出战 | +### S3-01 判定口径 + +- `S3-01` 只收口 M1 所需的最小合法参战规则:塔实例存在,且能解析到枪口 / 轴承 / 底座三个组件实例。 +- 当前阶段不把品质、Tag、耐久、属性强度纳入“合法参战塔”定义;这些仍分别留在 `S4`、`S5` 收口。 +- 流程层、库存层、UI 层后续都必须共用同一个判定入口,不能再分别维护“至少有塔”“参战区非空”“组件看起来完整”这类分散判断。 +- 判定结果不能只返回 `bool`,还要能区分至少以下失败原因:塔不存在、缺枪口、缺轴承、缺底座。 +- 批量校验参战区时,需要同时给出“合法参战塔列表”“非法参战塔及原因”“是否至少存在一座合法参战塔”三个结果,供 `S3-02 ~ S3-04` 直接复用。 + +> 2026-03-09 更新:`CombatParticipantTowerValidationService`、`CombatParticipantTowerValidationResult`、`CombatParticipantTowerValidationSummary` 已落地;Unity Test Runner 已确认相关 EditMode 测试通过。 + +### S3-02 当前落地状态 + +- 参战分配链路已统一改为返回结果对象,而不是只返回 `bool`。 +- `RepoFormUseCase`、`PlayerInventoryComponent`、`PlayerInventoryTowerRosterService`、`InventoryParticipantUtility` 已接入 `S3-01` 的统一合法性校验入口。 +- 当前可稳定区分以下分配失败原因:塔不存在、塔缺少三组件之一、已在参战区、参战区已满。 +- 组装区 / 参战区 UI 仍保持“失败时静默拦截”的当前策略,但日志链路已经能拿到明确失败原因,后续 `S3-04` 可直接复用。 +- 当前阶段仍未在战斗入口做最终出战前二次校验,这部分继续归 `S3-03`。 + +> 2026-03-09 更新:你已在 Unity Test Runner 中确认 `Assets/Tests/EditMode` 全部通过,其中包含 `CombatParticipantTowerValidationServiceTests` 与 `ParticipantTowerAssignResultTests`。 + ## 阶段 S4 - 收口品质 / Tag 规则 | 状态 | ID | 任务 | 交付物路径 | 验收标准 | @@ -130,18 +149,18 @@ ## 推荐执行顺序 1. 先做 `S1`,把主流程真正收成一条稳定可验收的链。 -2. 再做 `S2`,把当前临时 `NodeMapForm` 收成正式节点地图表现。 -3. 然后做 `S3`,补齐出战合法性,解决 `P0-10`。 +2. `S2` 已完成口径对齐,`NodeMapForm` 不再作为 M1 阻塞项。 +3. 接下来优先做 `S3`,补齐出战合法性,解决 `P0-10`。 4. 再做 `S4`,统一品质 / Tag 的规则口径,解决 `P0-11`。 5. 最后做 `S5`,决定耐久是完整收口还是同步缩范围,解决 `P0-12`。 6. 全部完成后做 `S6`,补测试并同步文档状态。 ## 本周建议开工顺序 -1. 先收 `S1-02 ~ S1-04` -2. 再收 `S2-01 ~ S2-03` -3. 然后收 `S3-01 ~ S3-04` -4. 最后再决定 `S4` 和 `S5` 是完整实现还是同步缩范围 +1. `S1` 与 `S2` 已完成口径收口,可直接进入规则侧收尾 +2. `S3-01`、`S3-02` 已完成,接下来先收 `S3-03`、`S3-04` +3. 再决定 `S4` 和 `S5` 是完整实现还是同步缩范围 +4. 最后补 `S6-01 ~ S6-04` ## 备注 diff --git a/docs/MVP-Scope.md.md b/docs/MVP-Scope.md similarity index 100% rename from docs/MVP-Scope.md.md rename to docs/MVP-Scope.md diff --git a/docs/TODO.md b/docs/TODO.md index 25b6564..82f25eb 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -9,10 +9,10 @@ 2. 每项任务必须同时满足“交付物路径”和“验收标准”才可打勾。 3. 数据驱动优先:数值、掉落、商店、事件都优先落到 `DataTables`。 -## M1 当前口径(2026-03-08) +## M1 当前口径(2026-03-09) - 当前仓库已经具备 `ProcedureMain + NodeMapForm + CombatNode + EventNode + ShopNode` 的主流程 Run 闭环,可以从主菜单进入游戏,完成固定 10 节点流程,并在 Boss 结算后进入正式结束态并回到主菜单。 -- M1 现在的真实缺口,不是“有没有 Run 雏形”,而是“节点地图表现是否收口为正式口径,以及合法出战 / 品质 / Tag / 耐久规则是否真正统一收口”。 +- `NodeMapForm` 已满足 MVP 所需的节点流程界面;M1 现在的真实缺口是合法出战 / 品质 / Tag / 耐久规则是否真正统一收口。 - `P0-10 ~ P0-12` 在代码里都已有局部实现,因此文档里统一按“部分完成但未收口”处理;只有满足最终验收标准后才可改成完成。 ## 里程碑 M1(P0)- 最小可玩闭环 @@ -23,7 +23,7 @@ | [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` 主流程闭环 | -| [~] | P0-05 | 实现 10 节点地图生成(最后节点固定 Boss) | `Assets/GameMain/Scripts/Procedure/`
`Assets/GameMain/Scripts/Scene/` | 已有固定 10 节点序列、当前节点限制与 Boss 终点链路,但 `NodeMapForm` 表现层仍未收口为正式节点地图 | +| [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/` | 结算奖励与惩罚严格匹配设计文档 | @@ -61,9 +61,9 @@ ## 本周建议开工顺序 -1. 先把 `P0-05` 的 `NodeMapForm` 表现层从当前占位地图收口成正式节点地图 -2. 完成 `P0-10`(把“至少有参战塔”提升为“满足完整合法参战条件才能出战”) -3. 再处理 `P0-11` ~ `P0-12`(先统一 M1 范围,再决定是完整收口还是同步缩范围) +1. 先完成 `P0-10`(把“至少有参战塔”提升为“满足完整合法参战条件才能出战”) +2. 再处理 `P0-11` ~ `P0-12`(先统一 M1 范围,再决定是完整收口还是同步缩范围) +3. 最后补关键流程 / 规则回归测试,并同步文档状态 ## 设计优化 Backlog(新增)