From af20eeecfaf4bfeb4ba8b8c825d01ce2e00e5e1d Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Mon, 9 Mar 2026 19:47:46 +0800 Subject: [PATCH] =?UTF-8?q?=E9=98=B6=E6=AE=B5=20S3=20-=20=E6=94=B6?= =?UTF-8?q?=E5=8F=A3=E5=87=BA=E6=88=98=E5=90=88=E6=B3=95=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Scripts/Procedure/ProcedureMain.cs | 210 +++++++++++++++++- .../EditMode/ProcedureMainServicesTests.cs | 199 +++++++++++++++++ docs/CodeX-TODO.md | 14 +- 3 files changed, 409 insertions(+), 14 deletions(-) diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs index fe269bb..e2989df 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain.cs @@ -1,3 +1,4 @@ +using System.Text; using GameFramework.Event; using GameFramework.Fsm; using GameFramework.Procedure; @@ -30,6 +31,22 @@ namespace GeometryTD.Procedure ReturnToMenu = 2 } + public enum ProcedureMainCombatEntryBlockReason + { + None = 0, + InventoryUnavailable = 1, + NoValidParticipantTower = 2 + } + + public sealed class ProcedureMainCombatEntryValidationResult + { + public bool CanEnterCombat => BlockReason == ProcedureMainCombatEntryBlockReason.None; + + public ProcedureMainCombatEntryBlockReason BlockReason { get; set; } + + public CombatParticipantTowerValidationSummary ValidationSummary { get; set; } + } + public static class ProcedureMainRunFlowService { public static ProcedureMainRunAdvanceResult TryAdvanceRun( @@ -95,6 +112,140 @@ namespace GeometryTD.Procedure } } + public static class ProcedureMainCombatEntryValidationService + { + public static ProcedureMainCombatEntryValidationResult Validate(BackpackInventoryData inventory) + { + if (inventory == null) + { + return new ProcedureMainCombatEntryValidationResult + { + BlockReason = ProcedureMainCombatEntryBlockReason.InventoryUnavailable, + ValidationSummary = new CombatParticipantTowerValidationSummary() + }; + } + + CombatParticipantTowerValidationSummary summary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(inventory); + + return new ProcedureMainCombatEntryValidationResult + { + BlockReason = summary.HasAnyValidParticipantTower + ? ProcedureMainCombatEntryBlockReason.None + : ProcedureMainCombatEntryBlockReason.NoValidParticipantTower, + ValidationSummary = summary + }; + } + + public static string BuildInvalidParticipantTowerLog( + CombatParticipantTowerValidationSummary summary) + { + if (summary?.InvalidResults == null || summary.InvalidResults.Count <= 0) + { + return "none"; + } + + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < summary.InvalidResults.Count; i++) + { + CombatParticipantTowerValidationResult result = summary.InvalidResults[i]; + if (result == null) + { + continue; + } + + if (builder.Length > 0) + { + builder.Append(", "); + } + + builder.Append('#'); + builder.Append(result.TowerInstanceId); + builder.Append(':'); + builder.Append(result.FailureReason); + } + + return builder.Length > 0 ? builder.ToString() : "none"; + } + + public static DialogFormRawData BuildBlockedCombatDialogRawData( + ProcedureMainCombatEntryValidationResult validationResult) + { + return new DialogFormRawData + { + Mode = 1, + Title = "无法进入战斗", + Message = BuildBlockedCombatDialogMessage(validationResult), + PauseGame = false, + ConfirmText = "知道了" + }; + } + + private static string BuildBlockedCombatDialogMessage( + ProcedureMainCombatEntryValidationResult validationResult) + { + if (validationResult == null) + { + return "当前无法确认出战信息,请稍后重试。"; + } + + switch (validationResult.BlockReason) + { + case ProcedureMainCombatEntryBlockReason.InventoryUnavailable: + return "当前无法读取库存快照,暂时不能进入战斗。请重新进入本轮流程后再试。"; + case ProcedureMainCombatEntryBlockReason.NoValidParticipantTower: + return BuildNoValidParticipantTowerMessage(validationResult.ValidationSummary); + default: + return "当前出战校验未通过,暂时不能进入战斗。"; + } + } + + private static string BuildNoValidParticipantTowerMessage( + CombatParticipantTowerValidationSummary summary) + { + if (summary?.InvalidResults == null || summary.InvalidResults.Count <= 0) + { + return "参战区至少需要 1 座完整装配了枪口、轴承、底座的塔,才能进入战斗。"; + } + + StringBuilder builder = new StringBuilder(); + builder.Append("参战区没有可出战的完整塔。"); + for (int i = 0; i < summary.InvalidResults.Count; i++) + { + CombatParticipantTowerValidationResult result = summary.InvalidResults[i]; + if (result == null) + { + continue; + } + + builder.Append('\n'); + builder.Append("塔 #"); + builder.Append(result.TowerInstanceId); + builder.Append(' '); + builder.Append(GetFailureReasonMessage(result.FailureReason)); + } + + return builder.ToString(); + } + + private static string GetFailureReasonMessage(CombatParticipantTowerValidationFailureReason failureReason) + { + switch (failureReason) + { + case CombatParticipantTowerValidationFailureReason.TowerMissing: + return "已不存在,无法参战。"; + case CombatParticipantTowerValidationFailureReason.MissingMuzzleComponent: + return "缺少枪口组件。"; + case CombatParticipantTowerValidationFailureReason.MissingBearingComponent: + return "缺少轴承组件。"; + case CombatParticipantTowerValidationFailureReason.MissingBaseComponent: + return "缺少底座组件。"; + default: + return "不满足当前参战条件。"; + } + } + } + public class ProcedureMain : ProcedureBase { public override bool UseNativeDialog => false; @@ -314,9 +465,13 @@ namespace GeometryTD.Procedure { case RunNodeType.Combat: case RunNodeType.BossCombat: - if (!HasAvailableParticipantTower()) + ProcedureMainCombatEntryValidationResult validationResult = + ProcedureMainCombatEntryValidationService.Validate( + GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null); + if (!validationResult.CanEnterCombat) { - Log.Warning("ProcedureMain blocked combat start. No participant tower is available."); + LogCombatEntryBlocked(currentNode, validationResult); + OpenBlockedCombatDialog(validationResult); return; } @@ -348,6 +503,51 @@ namespace GeometryTD.Procedure } } + private void OpenBlockedCombatDialog(ProcedureMainCombatEntryValidationResult validationResult) + { + GameEntry.UIRouter.CloseUI(UIFormType.DialogForm); + GameEntry.UIRouter.OpenUI( + UIFormType.DialogForm, + ProcedureMainCombatEntryValidationService.BuildBlockedCombatDialogRawData(validationResult)); + } + + private void LogCombatEntryBlocked( + RunNodeState currentNode, + ProcedureMainCombatEntryValidationResult validationResult) + { + switch (validationResult?.BlockReason) + { + case ProcedureMainCombatEntryBlockReason.InventoryUnavailable: + Log.Warning( + "ProcedureMain blocked combat start. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}, Reason={4}.", + _currentRunState?.RunId, + currentNode?.NodeId ?? 0, + currentNode?.NodeType ?? RunNodeType.None, + currentNode?.SequenceIndex ?? -1, + ProcedureMainCombatEntryBlockReason.InventoryUnavailable); + return; + case ProcedureMainCombatEntryBlockReason.NoValidParticipantTower: + Log.Warning( + "ProcedureMain blocked combat start. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}, Reason={4}, InvalidParticipantTowers={5}.", + _currentRunState?.RunId, + currentNode?.NodeId ?? 0, + currentNode?.NodeType ?? RunNodeType.None, + currentNode?.SequenceIndex ?? -1, + ProcedureMainCombatEntryBlockReason.NoValidParticipantTower, + ProcedureMainCombatEntryValidationService.BuildInvalidParticipantTowerLog( + validationResult.ValidationSummary)); + return; + default: + Log.Warning( + "ProcedureMain blocked combat start. RunId={0}, NodeId={1}, NodeType={2}, SequenceIndex={3}, Reason=Unknown.", + _currentRunState?.RunId, + currentNode?.NodeId ?? 0, + currentNode?.NodeType ?? RunNodeType.None, + currentNode?.SequenceIndex ?? -1); + return; + } + } + private void HandleRunAdvanceResult(ProcedureMainRunAdvanceResult result) { switch (result) @@ -375,12 +575,6 @@ namespace GeometryTD.Procedure } } - private static bool HasAvailableParticipantTower() - { - return GameEntry.PlayerInventory != null && - GameEntry.PlayerInventory.GetParticipantTowerSnapshot().Count > 0; - } - private void EnterHubFlow() { _flowPhase = ProcedureMainFlowPhase.Hub; diff --git a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs index 80d9278..342f6ce 100644 --- a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs @@ -1,5 +1,6 @@ using GeometryTD.Definition; using GeometryTD.Procedure; +using GeometryTD.UI; using NUnit.Framework; namespace GeometryTD.Tests.EditMode @@ -170,6 +171,171 @@ namespace GeometryTD.Tests.EditMode Assert.That(pendingResult, Is.EqualTo(ProcedureMainRunCompletionResult.NoChange)); } + [Test] + public void ValidateCombatEntry_Returns_InventoryUnavailable_When_Inventory_Is_Null() + { + ProcedureMainCombatEntryValidationResult result = + ProcedureMainCombatEntryValidationService.Validate(null); + + Assert.That(result.CanEnterCombat, Is.False); + Assert.That(result.BlockReason, Is.EqualTo(ProcedureMainCombatEntryBlockReason.InventoryUnavailable)); + Assert.That(result.ValidationSummary, Is.Not.Null); + Assert.That(result.ValidationSummary.HasAnyValidParticipantTower, Is.False); + } + + [Test] + public void ValidateCombatEntry_Returns_NoValidParticipantTower_When_ParticipantArea_Is_Empty() + { + BackpackInventoryData inventory = CreateCombatInventory(); + inventory.ParticipantTowerInstanceIds.Clear(); + + ProcedureMainCombatEntryValidationResult result = + ProcedureMainCombatEntryValidationService.Validate(inventory); + + Assert.That(result.CanEnterCombat, Is.False); + Assert.That(result.BlockReason, Is.EqualTo(ProcedureMainCombatEntryBlockReason.NoValidParticipantTower)); + Assert.That(result.ValidationSummary.HasAnyValidParticipantTower, Is.False); + Assert.That(result.ValidationSummary.ValidTowers.Count, Is.EqualTo(0)); + Assert.That(result.ValidationSummary.InvalidResults.Count, Is.EqualTo(0)); + } + + [Test] + public void ValidateCombatEntry_Returns_NoValidParticipantTower_When_AllParticipantTowers_Are_Invalid() + { + BackpackInventoryData inventory = CreateCombatInventory(); + inventory.Towers[0].BaseComponentInstanceId = 99903; + + ProcedureMainCombatEntryValidationResult result = + ProcedureMainCombatEntryValidationService.Validate(inventory); + + Assert.That(result.CanEnterCombat, Is.False); + Assert.That(result.BlockReason, Is.EqualTo(ProcedureMainCombatEntryBlockReason.NoValidParticipantTower)); + Assert.That(result.ValidationSummary.ValidTowers.Count, Is.EqualTo(0)); + Assert.That(result.ValidationSummary.InvalidResults.Count, Is.EqualTo(1)); + Assert.That( + result.ValidationSummary.InvalidResults[0].FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); + } + + [Test] + public void ValidateCombatEntry_Returns_CanEnter_When_At_Least_One_ParticipantTower_Is_Valid() + { + BackpackInventoryData inventory = CreateCombatInventory(); + inventory.Towers.Add(new TowerItemData + { + InstanceId = 90002, + Name = "非法塔", + MuzzleComponentInstanceId = 10001, + BearingComponentInstanceId = 20001, + BaseComponentInstanceId = 99903 + }); + inventory.ParticipantTowerInstanceIds.Add(90002); + + ProcedureMainCombatEntryValidationResult result = + ProcedureMainCombatEntryValidationService.Validate(inventory); + + Assert.That(result.CanEnterCombat, Is.True); + Assert.That(result.BlockReason, Is.EqualTo(ProcedureMainCombatEntryBlockReason.None)); + Assert.That(result.ValidationSummary.ValidTowers.Count, Is.EqualTo(1)); + Assert.That(result.ValidationSummary.ValidTowers[0].InstanceId, Is.EqualTo(90001)); + Assert.That(result.ValidationSummary.InvalidResults.Count, Is.EqualTo(1)); + } + + [Test] + public void BuildInvalidParticipantTowerLog_Returns_None_When_InvalidResults_Are_Empty() + { + string log = ProcedureMainCombatEntryValidationService.BuildInvalidParticipantTowerLog( + new CombatParticipantTowerValidationSummary()); + + Assert.That(log, Is.EqualTo("none")); + } + + [Test] + public void BuildInvalidParticipantTowerLog_Formats_TowerIds_And_Reasons() + { + CombatParticipantTowerValidationSummary summary = new CombatParticipantTowerValidationSummary + { + InvalidResults = new[] + { + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90002, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingBaseComponent + }, + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90003, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingBearingComponent + } + } + }; + + string log = ProcedureMainCombatEntryValidationService.BuildInvalidParticipantTowerLog(summary); + + Assert.That(log, Is.EqualTo("#90002:MissingBaseComponent, #90003:MissingBearingComponent")); + } + + [Test] + public void BuildBlockedCombatDialogRawData_Returns_InventoryUnavailable_Message() + { + DialogFormRawData rawData = ProcedureMainCombatEntryValidationService.BuildBlockedCombatDialogRawData( + new ProcedureMainCombatEntryValidationResult + { + BlockReason = ProcedureMainCombatEntryBlockReason.InventoryUnavailable, + ValidationSummary = new CombatParticipantTowerValidationSummary() + }); + + Assert.That(rawData.Title, Is.EqualTo("无法进入战斗")); + Assert.That(rawData.Message, Is.EqualTo("当前无法读取库存快照,暂时不能进入战斗。请重新进入本轮流程后再试。")); + Assert.That(rawData.ConfirmText, Is.EqualTo("知道了")); + Assert.That(rawData.Mode, Is.EqualTo(1)); + } + + [Test] + public void BuildBlockedCombatDialogRawData_Returns_Generic_Message_When_NoParticipantTower_Is_Assigned() + { + DialogFormRawData rawData = ProcedureMainCombatEntryValidationService.BuildBlockedCombatDialogRawData( + new ProcedureMainCombatEntryValidationResult + { + BlockReason = ProcedureMainCombatEntryBlockReason.NoValidParticipantTower, + ValidationSummary = new CombatParticipantTowerValidationSummary() + }); + + Assert.That( + rawData.Message, + Is.EqualTo("参战区至少需要 1 座完整装配了枪口、轴承、底座的塔,才能进入战斗。")); + } + + [Test] + public void BuildBlockedCombatDialogRawData_Lists_Invalid_Tower_Reasons() + { + DialogFormRawData rawData = ProcedureMainCombatEntryValidationService.BuildBlockedCombatDialogRawData( + new ProcedureMainCombatEntryValidationResult + { + BlockReason = ProcedureMainCombatEntryBlockReason.NoValidParticipantTower, + ValidationSummary = new CombatParticipantTowerValidationSummary + { + InvalidResults = new[] + { + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90002, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingBaseComponent + }, + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90003, + FailureReason = CombatParticipantTowerValidationFailureReason.MissingMuzzleComponent + } + } + } + }); + + Assert.That( + rawData.Message, + Is.EqualTo("参战区没有可出战的完整塔。\n塔 #90002 缺少底座组件。\n塔 #90003 缺少枪口组件。")); + } + private static RunState CreateTwoNodeRun() { return RunStateFactory.Create( @@ -181,5 +347,38 @@ namespace GeometryTD.Tests.EditMode new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Event } }); } + + private static BackpackInventoryData CreateCombatInventory() + { + 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 + }); + inventory.ParticipantTowerInstanceIds.Add(90001); + return inventory; + } } } diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index 1c3d8cb..d97210d 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -96,8 +96,8 @@ |-----|-------|------------------|--------------------------------------------------------------------------------------------------|--------------------| | [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/` | 玩家知道为什么不能出战 | +| [x] | S3-03 | 在战斗入口补齐最终出战校验 | `Assets/GameMain/Scripts/Procedure/` | 不满足完整条件时无法进入战斗节点 | +| [x] | S3-04 | 给出 UI / 日志层明确反馈 | `Assets/GameMain/Scripts/UI/Game/`
`Assets/GameMain/Scripts/Procedure/` | 玩家知道为什么不能出战 | ### S3-01 判定口径 @@ -114,10 +114,12 @@ - 参战分配链路已统一改为返回结果对象,而不是只返回 `bool`。 - `RepoFormUseCase`、`PlayerInventoryComponent`、`PlayerInventoryTowerRosterService`、`InventoryParticipantUtility` 已接入 `S3-01` 的统一合法性校验入口。 - 当前可稳定区分以下分配失败原因:塔不存在、塔缺少三组件之一、已在参战区、参战区已满。 -- 组装区 / 参战区 UI 仍保持“失败时静默拦截”的当前策略,但日志链路已经能拿到明确失败原因,后续 `S3-04` 可直接复用。 -- 当前阶段仍未在战斗入口做最终出战前二次校验,这部分继续归 `S3-03`。 +- 组装区 / 参战区 UI 仍保持“失败时静默拦截”的当前策略;战斗入口已补最终二次校验,避免旧快照、脏状态或外部改动绕过 `S3-02` 直接进入战斗。 +- 战斗入口校验失败时,`ProcedureMain` 现在会同时写明失败日志,并弹出 `DialogForm` 给出明确原因;空参战区会提示“至少需要 1 座完整装配了枪口、轴承、底座的塔”,脏数据则会列出具体缺失组件。 > 2026-03-09 更新:你已在 Unity Test Runner 中确认 `Assets/Tests/EditMode` 全部通过,其中包含 `CombatParticipantTowerValidationServiceTests` 与 `ParticipantTowerAssignResultTests`。 +> +> 2026-03-09 更新:`ProcedureMainCombatEntryValidationService` 与战斗入口拦截已落地;你已确认 Unity Test Runner 全部通过,并手工验证了“空参战区开始战斗”会被拦截且弹出明确提示。 ## 阶段 S4 - 收口品质 / Tag 规则 @@ -158,8 +160,8 @@ ## 本周建议开工顺序 1. `S1` 与 `S2` 已完成口径收口,可直接进入规则侧收尾 -2. `S3-01`、`S3-02` 已完成,接下来先收 `S3-03`、`S3-04` -3. 再决定 `S4` 和 `S5` 是完整实现还是同步缩范围 +2. `S3-01 ~ S3-04` 已完成,当前可转入 `S4` +3. 接下来决定 `S4` 和 `S5` 是完整实现还是同步缩范围 4. 最后补 `S6-01 ~ S6-04` ## 备注