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`
## 备注