From 113decb414e77854f1affde195d114f60838395f Mon Sep 17 00:00:00 2001 From: SepComet <2428390463@qq.com> Date: Wed, 11 Mar 2026 15:36:18 +0800 Subject: [PATCH] S5-01 + S5-02 + S5-03 --- .../CombatScheduler/CombatRunResourceStore.cs | 17 +- .../CombatSettlementContext.cs | 17 +- .../CombatSettlementService.cs | 61 ++++-- .../EnemyDrop/EnemyDropContext.cs | 2 +- .../EnemyDrop/EnemyDropResolver.cs | 2 +- .../EnemyDrop/EnemyDropResult.cs | 2 +- .../PlayerInventoryComponent.cs | 5 +- .../PlayerInventoryStateStore.cs | 8 +- .../PlayerInventoryTowerRosterService.cs | 23 +- .../CombatParticipantTowerValidation.cs | 48 ++++- ...ocedureMainCombatEntryValidationService.cs | 8 +- ...tParticipantTowerValidationServiceTests.cs | 54 ++++- .../EditMode/CombatSettlementServiceTests.cs | 197 ++++++++++++++++++ .../CombatSettlementServiceTests.cs.meta | 11 + .../ParticipantTowerAssignResultTests.cs | 15 ++ .../PlayerInventoryTowerRosterServiceTests.cs | 98 +++++++++ ...erInventoryTowerRosterServiceTests.cs.meta | 11 + .../EditMode/ProcedureMainServicesTests.cs | 48 +++++ 18 files changed, 570 insertions(+), 57 deletions(-) create mode 100644 Assets/Tests/EditMode/CombatSettlementServiceTests.cs create mode 100644 Assets/Tests/EditMode/CombatSettlementServiceTests.cs.meta create mode 100644 Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs create mode 100644 Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs.meta diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatRunResourceStore.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatRunResourceStore.cs index b86db61..2af5d12 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatRunResourceStore.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatRunResourceStore.cs @@ -7,7 +7,7 @@ using UnityEngine; namespace GeometryTD.CustomComponent { - internal sealed class CombatRunResourceStore + public sealed class CombatRunResourceStore { private readonly List _buildTowerStatsSnapshot = new(); private readonly List _participantTowerSnapshot = new(); @@ -88,6 +88,21 @@ namespace GeometryTD.CustomComponent return snapshot; } + public IReadOnlyList GetParticipantTowerInstanceIdSnapshot() + { + List snapshot = new List(_participantTowerSnapshot.Count); + for (int i = 0; i < _participantTowerSnapshot.Count; i++) + { + TowerItemData tower = _participantTowerSnapshot[i]; + if (tower != null && tower.InstanceId > 0) + { + snapshot.Add(tower.InstanceId); + } + } + + return snapshot; + } + public bool TryConsumeCoin(int coin) { int requiredCoin = Mathf.Max(0, coin); diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs index 527ce96..068362f 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementContext.cs @@ -1,22 +1,23 @@ +using System.Collections.Generic; using GeometryTD.Definition; namespace GeometryTD.CustomComponent { - internal sealed class CombatSettlementContext + public sealed class CombatSettlementContext { public CombatSettlementFlags Flags { get; } = new(); public CombatSettlementResult Result { get; } = new(); public CombatSettlementSummary Summary { get; } = new(); } - internal sealed class CombatSettlementFlags + public sealed class CombatSettlementFlags { public bool ShouldOpenRewardSelection; public bool DidEnterRewardSelection; public bool IsCommitted; } - internal sealed class CombatSettlementResult + public sealed class CombatSettlementResult { public bool DidCombatWin; public int FinalCoin; @@ -25,17 +26,17 @@ namespace GeometryTD.CustomComponent public int DefeatedEnemyCount; public int GainedGold; public BackpackInventoryData RewardInventory; - public CombatSettlementPenaltyResult Penalty { get; } = new(); + public CombatSettlementEnduranceResult Endurance { get; } = new(); } - internal sealed class CombatSettlementPenaltyResult + public sealed class CombatSettlementEnduranceResult { - public bool ShouldApplyLowBaseHpPenalty; - public float LowBaseHpEndurancePenaltyValue; + public List TargetTowerInstanceIds { get; } = new(); + public float EnduranceLossPerComponent; public int AffectedTowerCount; } - internal sealed class CombatSettlementSummary + public sealed class CombatSettlementSummary { public int DefeatedEnemyCount; public int GainedGold; diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs index 13e461f..0f47fff 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs @@ -8,14 +8,13 @@ using UnityGameFramework.Runtime; namespace GeometryTD.CustomComponent { - internal sealed class CombatSettlementService + public sealed class CombatSettlementService { private const int RewardSelectDisplayCount = 3; private const float FullBaseHpGoldBonusRate = 0.3f; private const float HighBaseHpGoldBonusRate = 0.1f; private const float HighBaseHpThreshold = 0.8f; - private const float MidBaseHpThreshold = 0.5f; - private const float LowBaseHpTowerEndurancePenalty = 10f; + private const float SettlementTowerEnduranceLoss = 1f; public CombatSettlementContext BuildSettlementContext( bool didCombatWin, @@ -33,7 +32,6 @@ namespace GeometryTD.CustomComponent out int levelRewardGold, out float bonusRate, out int bonusGold, - out bool appliedLowBaseHpPenalty, out shouldOpenFullBaseHpRewardSelect); CombatSettlementContext settlementContext = new CombatSettlementContext @@ -48,9 +46,7 @@ namespace GeometryTD.CustomComponent settlementContext.Result.RewardInventory = resourceStore != null ? resourceStore.GetRewardInventorySnapshot() : new BackpackInventoryData(); - settlementContext.Result.Penalty.ShouldApplyLowBaseHpPenalty = appliedLowBaseHpPenalty; - settlementContext.Result.Penalty.LowBaseHpEndurancePenaltyValue = - appliedLowBaseHpPenalty ? LowBaseHpTowerEndurancePenalty : 0f; + PopulateEnduranceSettlement(settlementContext, resourceStore); settlementContext.Flags.ShouldOpenRewardSelection = shouldOpenFullBaseHpRewardSelect; settlementContext.Flags.DidEnterRewardSelection = false; settlementContext.Summary.DefeatedEnemyCount = settlementContext.Result.DefeatedEnemyCount; @@ -58,7 +54,7 @@ namespace GeometryTD.CustomComponent settlementContext.Summary.RewardInventory = settlementContext.Result.RewardInventory; Log.Info( - "Combat settlement resolved. Level={0}, BaseHp={1}/{2}, LevelReward={3}, BonusRate={4:P0}, BonusGold={5}, FullHpRewardSelect={6}, LowHpPenalty={7}.", + "Combat settlement resolved. Level={0}, BaseHp={1}/{2}, LevelReward={3}, BonusRate={4:P0}, BonusGold={5}, FullHpRewardSelect={6}, EnduranceTargets={7}.", currentLevel != null ? currentLevel.Id : 0, currentBaseHp, maxBaseHp, @@ -66,7 +62,7 @@ namespace GeometryTD.CustomComponent bonusRate, bonusGold, shouldOpenFullBaseHpRewardSelect, - appliedLowBaseHpPenalty); + settlementContext.Result.Endurance.TargetTowerInstanceIds.Count); return settlementContext; } @@ -81,7 +77,7 @@ namespace GeometryTD.CustomComponent GameEntry.PlayerInventory?.MergeInventory(rewardInventory); settlementContext.Result.RewardInventory = rewardInventory; settlementContext.Summary.RewardInventory = rewardInventory; - settlementContext.Result.Penalty.AffectedTowerCount = ApplyDeferredSettlementPenalty(settlementContext); + settlementContext.Result.Endurance.AffectedTowerCount = ApplyDeferredSettlementEndurance(settlementContext); settlementContext.Flags.IsCommitted = true; } @@ -181,7 +177,6 @@ namespace GeometryTD.CustomComponent out int levelRewardGold, out float bonusRate, out int bonusGold, - out bool appliedLowBaseHpPenalty, out bool shouldOpenFullBaseHpRewardSelect) { currentBaseHp = Mathf.Max(0, resourceStore != null ? resourceStore.CurrentBaseHp : 0); @@ -194,7 +189,6 @@ namespace GeometryTD.CustomComponent levelRewardGold = currentLevel != null ? Mathf.Max(0, currentLevel.RewardGold) : 0; bonusRate = 0f; bonusGold = 0; - appliedLowBaseHpPenalty = false; shouldOpenFullBaseHpRewardSelect = false; if (!didCombatWin || resourceStore == null) @@ -214,10 +208,6 @@ namespace GeometryTD.CustomComponent { bonusRate = HighBaseHpGoldBonusRate; } - else if (hpRate < MidBaseHpThreshold) - { - appliedLowBaseHpPenalty = true; - } } int goldForBonusCalculation = Mathf.Max(0, resourceStore.GainedGold) + levelRewardGold; @@ -226,11 +216,40 @@ namespace GeometryTD.CustomComponent resourceStore.AddSettlementGold(settlementGold); } - private static int ApplyDeferredSettlementPenalty(CombatSettlementContext settlementContext) + private static void PopulateEnduranceSettlement( + CombatSettlementContext settlementContext, + CombatRunResourceStore resourceStore) + { + if (settlementContext == null) + { + return; + } + + CombatSettlementEnduranceResult enduranceResult = settlementContext.Result.Endurance; + enduranceResult.TargetTowerInstanceIds.Clear(); + enduranceResult.EnduranceLossPerComponent = SettlementTowerEnduranceLoss; + + IReadOnlyList participantTowerIds = resourceStore?.GetParticipantTowerInstanceIdSnapshot(); + if (participantTowerIds == null || participantTowerIds.Count <= 0) + { + return; + } + + for (int i = 0; i < participantTowerIds.Count; i++) + { + long towerId = participantTowerIds[i]; + if (towerId > 0) + { + enduranceResult.TargetTowerInstanceIds.Add(towerId); + } + } + } + + private static int ApplyDeferredSettlementEndurance(CombatSettlementContext settlementContext) { if (settlementContext == null || - !settlementContext.Result.Penalty.ShouldApplyLowBaseHpPenalty || - settlementContext.Result.Penalty.LowBaseHpEndurancePenaltyValue <= 0f) + settlementContext.Result.Endurance.EnduranceLossPerComponent <= 0f || + settlementContext.Result.Endurance.TargetTowerInstanceIds.Count <= 0) { return 0; } @@ -241,7 +260,9 @@ namespace GeometryTD.CustomComponent return 0; } - return inventory.ReduceAllTowerEndurance(settlementContext.Result.Penalty.LowBaseHpEndurancePenaltyValue); + return inventory.ReduceTowerEndurance( + settlementContext.Result.Endurance.TargetTowerInstanceIds, + settlementContext.Result.Endurance.EnduranceLossPerComponent); } private static bool TryAppendRewardComponent(BackpackInventoryData rewardInventory, TowerCompItemData selectedItem) diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropContext.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropContext.cs index d09cad0..57e1289 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropContext.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropContext.cs @@ -3,7 +3,7 @@ using GeometryTD.Definition; namespace GeometryTD.CustomComponent { - internal readonly struct EnemyDropContext + public readonly struct EnemyDropContext { public EnemyDropContext(DREnemy enemy, int displayPhaseIndex, LevelThemeType themeType) { diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs index d9ab49e..bf59402 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResolver.cs @@ -8,7 +8,7 @@ using Random = UnityEngine.Random; namespace GeometryTD.CustomComponent { - internal sealed class EnemyDropResolver + public sealed class EnemyDropResolver { private const float DropChanceBase = 0.05f; private const float DropChancePerPhase = 0.2f; diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResult.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResult.cs index 132efb4..50f5cdd 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResult.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/EnemyDrop/EnemyDropResult.cs @@ -2,7 +2,7 @@ using GeometryTD.Definition; namespace GeometryTD.CustomComponent { - internal readonly struct EnemyDropResult + public readonly struct EnemyDropResult { public static EnemyDropResult Empty => new(0, 0, null); diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs index 6214604..4c5db05 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryComponent.cs @@ -132,10 +132,10 @@ namespace GeometryTD.CustomComponent out assembledTower); } - public int ReduceAllTowerEndurance(float enduranceLoss) + public int ReduceTowerEndurance(IReadOnlyList towerInstanceIds, float enduranceLoss) { EnsureInitialized(); - return _towerRosterService.ReduceAllTowerEndurance(enduranceLoss); + return _towerRosterService.ReduceTowerEndurance(towerInstanceIds, enduranceLoss); } private void EnsureInitialized() @@ -158,4 +158,3 @@ namespace GeometryTD.CustomComponent } } } - diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryStateStore.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryStateStore.cs index e95099f..a3fd94f 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryStateStore.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryStateStore.cs @@ -6,7 +6,7 @@ using UnityEngine; namespace GeometryTD.CustomComponent { - internal struct PlayerInventoryMergeSummary + public struct PlayerInventoryMergeSummary { public int GainedGold; public int GainedMuzzleCount; @@ -19,14 +19,14 @@ namespace GeometryTD.CustomComponent GainedTowerCount > 0; } - internal sealed class PlayerInventoryState + public sealed class PlayerInventoryState { public BackpackInventoryData Inventory = new BackpackInventoryData(); public long NextInstanceId = 1; public bool IsInitialized; } - internal sealed class PlayerInventoryQueryModel + public sealed class PlayerInventoryQueryModel { private readonly PlayerInventoryState _state; @@ -72,7 +72,7 @@ namespace GeometryTD.CustomComponent } } - internal sealed class PlayerInventoryCommandModel + public sealed class PlayerInventoryCommandModel { private readonly PlayerInventoryState _state; diff --git a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs index 2948af7..2a6bd79 100644 --- a/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/PlayerInventory/PlayerInventoryTowerRosterService.cs @@ -5,7 +5,7 @@ using UnityEngine; namespace GeometryTD.CustomComponent { - internal sealed class PlayerInventoryTowerRosterService + public sealed class PlayerInventoryTowerRosterService { private readonly PlayerInventoryQueryModel _queryModel; private readonly int _maxParticipantTowerCount; @@ -34,11 +34,15 @@ namespace GeometryTD.CustomComponent _maxParticipantTowerCount); } - public int ReduceAllTowerEndurance(float enduranceLoss) + public int ReduceTowerEndurance(IReadOnlyList towerInstanceIds, float enduranceLoss) { float resolvedLoss = Mathf.Max(0f, enduranceLoss); BackpackInventoryData inventory = _queryModel.Inventory; - if (resolvedLoss <= 0f || inventory.Towers == null || inventory.Towers.Count <= 0) + if (resolvedLoss <= 0f || + towerInstanceIds == null || + towerInstanceIds.Count <= 0 || + inventory.Towers == null || + inventory.Towers.Count <= 0) { return 0; } @@ -46,12 +50,19 @@ namespace GeometryTD.CustomComponent Dictionary muzzleMap = BuildComponentMap(inventory.MuzzleComponents); Dictionary bearingMap = BuildComponentMap(inventory.BearingComponents); Dictionary baseMap = BuildComponentMap(inventory.BaseComponents); + HashSet processedTowerIds = new HashSet(); int affectedCount = 0; - for (int i = 0; i < inventory.Towers.Count; i++) + for (int i = 0; i < towerInstanceIds.Count; i++) { - TowerItemData tower = inventory.Towers[i]; - if (tower == null) + long towerInstanceId = towerInstanceIds[i]; + if (towerInstanceId <= 0 || !processedTowerIds.Add(towerInstanceId)) + { + continue; + } + + if (!InventoryParticipantUtility.TryGetTowerById(inventory, towerInstanceId, out TowerItemData tower) || + tower == null) { continue; } diff --git a/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs index 0a8c3cf..3272d4e 100644 --- a/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs +++ b/Assets/GameMain/Scripts/Definition/CombatParticipantTowerValidation.cs @@ -8,7 +8,10 @@ namespace GeometryTD.Definition TowerMissing = 1, MissingMuzzleComponent = 2, MissingBearingComponent = 3, - MissingBaseComponent = 4 + MissingBaseComponent = 4, + BrokenMuzzleComponent = 5, + BrokenBearingComponent = 6, + BrokenBaseComponent = 7 } public sealed class CombatParticipantTowerValidationResult @@ -64,7 +67,7 @@ namespace GeometryTD.Definition }; } - if (!HasComponent(inventory?.MuzzleComponents, tower.MuzzleComponentInstanceId)) + if (!TryGetComponentById(inventory?.MuzzleComponents, tower.MuzzleComponentInstanceId, out MuzzleCompItemData muzzleComponent)) { return new CombatParticipantTowerValidationResult { @@ -74,7 +77,17 @@ namespace GeometryTD.Definition }; } - if (!HasComponent(inventory?.BearingComponents, tower.BearingComponentInstanceId)) + if (muzzleComponent.Endurance <= 0f) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent + }; + } + + if (!TryGetComponentById(inventory?.BearingComponents, tower.BearingComponentInstanceId, out BearingCompItemData bearingComponent)) { return new CombatParticipantTowerValidationResult { @@ -84,7 +97,17 @@ namespace GeometryTD.Definition }; } - if (!HasComponent(inventory?.BaseComponents, tower.BaseComponentInstanceId)) + if (bearingComponent.Endurance <= 0f) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.BrokenBearingComponent + }; + } + + if (!TryGetComponentById(inventory?.BaseComponents, tower.BaseComponentInstanceId, out BaseCompItemData baseComponent)) { return new CombatParticipantTowerValidationResult { @@ -94,6 +117,16 @@ namespace GeometryTD.Definition }; } + if (baseComponent.Endurance <= 0f) + { + return new CombatParticipantTowerValidationResult + { + TowerInstanceId = tower.InstanceId, + Tower = tower, + FailureReason = CombatParticipantTowerValidationFailureReason.BrokenBaseComponent + }; + } + return new CombatParticipantTowerValidationResult { TowerInstanceId = tower.InstanceId, @@ -165,9 +198,13 @@ namespace GeometryTD.Definition return false; } - private static bool HasComponent(IReadOnlyList components, long componentInstanceId) + private static bool TryGetComponentById( + IReadOnlyList components, + long componentInstanceId, + out TComponent resolvedComponent) where TComponent : TowerCompItemData { + resolvedComponent = null; if (components == null || componentInstanceId <= 0) { return false; @@ -178,6 +215,7 @@ namespace GeometryTD.Definition TComponent component = components[i]; if (component != null && component.InstanceId == componentInstanceId) { + resolvedComponent = component; return true; } } diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainCombatEntryValidationService.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainCombatEntryValidationService.cs index 98857ad..9093b20 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainCombatEntryValidationService.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainCombatEntryValidationService.cs @@ -132,9 +132,15 @@ namespace GeometryTD.Procedure return "缺少轴承组件。"; case CombatParticipantTowerValidationFailureReason.MissingBaseComponent: return "缺少底座组件。"; + case CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent: + return "枪口组件耐久为 0,无法参战。"; + case CombatParticipantTowerValidationFailureReason.BrokenBearingComponent: + return "轴承组件耐久为 0,无法参战。"; + case CombatParticipantTowerValidationFailureReason.BrokenBaseComponent: + return "底座组件耐久为 0,无法参战。"; default: return "不满足当前参战条件。"; } } } -} \ No newline at end of file +} diff --git a/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs index b3f9714..31a8fff 100644 --- a/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs +++ b/Assets/Tests/EditMode/CombatParticipantTowerValidationServiceTests.cs @@ -75,6 +75,48 @@ namespace GeometryTD.Tests.EditMode Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); } + [Test] + public void ValidateTower_Returns_BrokenMuzzle_When_Muzzle_Endurance_Is_Zero() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.MuzzleComponents[0].Endurance = 0f; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent)); + } + + [Test] + public void ValidateTower_Returns_BrokenBearing_When_Bearing_Endurance_Is_Zero() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.BearingComponents[0].Endurance = 0f; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenBearingComponent)); + } + + [Test] + public void ValidateTower_Returns_BrokenBase_When_Base_Endurance_Is_Zero() + { + BackpackInventoryData inventory = CreateInventory(); + inventory.BaseComponents[0].Endurance = 0f; + + CombatParticipantTowerValidationResult result = + CombatParticipantTowerValidationService.ValidateTower(inventory, inventory.Towers[0]); + + Assert.That(result.IsValid, Is.False); + Assert.That(result.FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenBaseComponent)); + } + [Test] public void ValidateParticipantTowers_Splits_Valid_And_Invalid_Towers() { @@ -103,19 +145,19 @@ namespace GeometryTD.Tests.EditMode } [Test] - public void ValidateParticipantTowers_Treats_Zero_Endurance_As_Valid_For_S3_01() + public void ValidateParticipantTowers_Treats_Zero_Endurance_As_Invalid_For_S5_02() { 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)); + Assert.That(summary.HasAnyValidParticipantTower, Is.False); + Assert.That(summary.ValidTowers.Count, Is.EqualTo(0)); + Assert.That(summary.InvalidResults.Count, Is.EqualTo(1)); + Assert.That(summary.InvalidResults[0].FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent)); } [Test] diff --git a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs new file mode 100644 index 0000000..9e51d88 --- /dev/null +++ b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs @@ -0,0 +1,197 @@ +using System.Collections.Generic; +using System.Reflection; +using GeometryTD.CustomComponent; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using NUnit.Framework; +using UnityEngine; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class CombatSettlementServiceTests + { + private const int MaxParticipantTowerCount = 4; + + private GameObject _inventoryObject; + private PlayerInventoryComponent _originalPlayerInventory; + + [TearDown] + public void TearDown() + { + SetStaticPlayerInventory(_originalPlayerInventory); + if (_inventoryObject != null) + { + Object.DestroyImmediate(_inventoryObject); + _inventoryObject = null; + } + } + + [Test] + public void BuildSettlementContext_Captures_CombatStart_Participant_Tower_Ids_And_Fixed_Endurance_Loss() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + + BackpackInventoryData changedInventory = inventoryComponent.GetInventorySnapshot(); + changedInventory.ParticipantTowerInstanceIds.Clear(); + changedInventory.ParticipantTowerInstanceIds.Add(90003); + inventoryComponent.ReplaceInventorySnapshot(changedInventory); + + CombatSettlementContext settlementContext = new CombatSettlementService().BuildSettlementContext( + didCombatWin: false, + currentLevel: level, + defeatedEnemyCount: 4, + resourceStore: resourceStore); + + CollectionAssert.AreEqual( + new long[] { 90001, 90002 }, + settlementContext.Result.Endurance.TargetTowerInstanceIds); + Assert.That(settlementContext.Result.Endurance.EnduranceLossPerComponent, Is.EqualTo(1f)); + Assert.That(settlementContext.Flags.ShouldOpenRewardSelection, Is.False); + } + + [Test] + public void CommitSettlementInventory_Reduces_Only_CombatStart_Participant_Towers() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); + CombatRunResourceStore resourceStore = new CombatRunResourceStore(); + DRLevel level = CreateLevel(baseHp: 100, startCoin: 20, rewardGold: 30); + resourceStore.InitializeForCombat(level); + + BackpackInventoryData changedInventory = inventoryComponent.GetInventorySnapshot(); + changedInventory.ParticipantTowerInstanceIds.Clear(); + changedInventory.ParticipantTowerInstanceIds.Add(90003); + inventoryComponent.ReplaceInventorySnapshot(changedInventory); + + CombatSettlementService settlementService = new CombatSettlementService(); + CombatSettlementContext settlementContext = settlementService.BuildSettlementContext( + didCombatWin: false, + currentLevel: level, + defeatedEnemyCount: 2, + resourceStore: resourceStore); + + settlementService.CommitSettlementInventory(settlementContext); + + BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot(); + Assert.That(GetTowerComponents(committedInventory, 90001).Muzzle.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(committedInventory, 90002).Muzzle.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(committedInventory, 90003).Muzzle.Endurance, Is.EqualTo(100f)); + Assert.That(settlementContext.Result.Endurance.AffectedTowerCount, Is.EqualTo(2)); + } + + [Test] + public void CommitSettlementInventory_Reduces_Endurance_To_Zero_And_Next_Validation_Fails() + { + PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateInventory(1f, 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: 6, + resourceStore: resourceStore); + + settlementService.CommitSettlementInventory(settlementContext); + + BackpackInventoryData committedInventory = inventoryComponent.GetInventorySnapshot(); + CombatParticipantTowerValidationSummary summary = + CombatParticipantTowerValidationService.ValidateParticipantTowers(committedInventory); + + Assert.That(GetTowerComponents(committedInventory, 90001).Muzzle.Endurance, Is.EqualTo(0f)); + Assert.That(summary.HasAnyValidParticipantTower, Is.True); + Assert.That(summary.InvalidResults.Count, Is.EqualTo(1)); + Assert.That( + summary.InvalidResults[0].FailureReason, + Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent)); + } + + private PlayerInventoryComponent CreateBoundPlayerInventory(BackpackInventoryData inventory) + { + _originalPlayerInventory = GameEntry.PlayerInventory; + _inventoryObject = new GameObject("TestPlayerInventory"); + PlayerInventoryComponent inventoryComponent = _inventoryObject.AddComponent(); + SetStaticPlayerInventory(inventoryComponent); + inventoryComponent.ReplaceInventorySnapshot(inventory); + return inventoryComponent; + } + + private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent) + { + FieldInfo backingField = typeof(GameEntry).GetField( + "k__BackingField", + BindingFlags.Static | BindingFlags.NonPublic); + Assert.That(backingField, Is.Not.Null); + backingField.SetValue(null, inventoryComponent); + } + + private static DRLevel CreateLevel(int baseHp, int startCoin, int rewardGold) + { + DRLevel level = new DRLevel(); + bool parsed = level.ParseDataRow( + $"\t1\t测试关卡\tPlain\t{baseHp}\t{startCoin}\tWaveCount\t10\t{rewardGold}", + null); + Assert.That(parsed, Is.True); + return level; + } + + private static BackpackInventoryData CreateInventory( + float firstTowerEndurance, + float secondTowerEndurance, + float thirdTowerEndurance) + { + BackpackInventoryData inventory = new BackpackInventoryData(); + AddTower(inventory, 90001, 10001, 20001, 30001, firstTowerEndurance, isParticipant: true); + AddTower(inventory, 90002, 10002, 20002, 30002, secondTowerEndurance, isParticipant: true); + AddTower(inventory, 90003, 10003, 20003, 30003, thirdTowerEndurance, isParticipant: false); + return inventory; + } + + private static void AddTower( + BackpackInventoryData inventory, + long towerId, + long muzzleId, + long bearingId, + long baseId, + float endurance, + bool isParticipant) + { + inventory.MuzzleComponents.Add(new MuzzleCompItemData { InstanceId = muzzleId, Endurance = endurance }); + inventory.BearingComponents.Add(new BearingCompItemData { InstanceId = bearingId, Endurance = endurance }); + inventory.BaseComponents.Add(new BaseCompItemData { InstanceId = baseId, Endurance = endurance }); + inventory.Towers.Add(new TowerItemData + { + InstanceId = towerId, + MuzzleComponentInstanceId = muzzleId, + BearingComponentInstanceId = bearingId, + BaseComponentInstanceId = baseId, + IsParticipatingInCombat = isParticipant, + Stats = new TowerStatsData() + }); + + if (isParticipant && inventory.ParticipantTowerInstanceIds.Count < MaxParticipantTowerCount) + { + inventory.ParticipantTowerInstanceIds.Add(towerId); + } + } + + private static (MuzzleCompItemData Muzzle, BearingCompItemData Bearing, BaseCompItemData Base) GetTowerComponents( + BackpackInventoryData inventory, + long towerInstanceId) + { + TowerItemData tower = inventory.Towers.Find(item => item.InstanceId == towerInstanceId); + Assert.That(tower, Is.Not.Null); + MuzzleCompItemData muzzle = inventory.MuzzleComponents.Find(item => item.InstanceId == tower.MuzzleComponentInstanceId); + BearingCompItemData bearing = inventory.BearingComponents.Find(item => item.InstanceId == tower.BearingComponentInstanceId); + BaseCompItemData baseComp = inventory.BaseComponents.Find(item => item.InstanceId == tower.BaseComponentInstanceId); + Assert.That(muzzle, Is.Not.Null); + Assert.That(bearing, Is.Not.Null); + Assert.That(baseComp, Is.Not.Null); + return (muzzle, bearing, baseComp); + } + } +} diff --git a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs.meta b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs.meta new file mode 100644 index 0000000..3bf1758 --- /dev/null +++ b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7a9e6c3fb1d84d6db2455d4c13f0e2b9 +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 index 0452887..35ffb57 100644 --- a/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs +++ b/Assets/Tests/EditMode/ParticipantTowerAssignResultTests.cs @@ -51,6 +51,21 @@ namespace GeometryTD.Tests.EditMode Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); } + [Test] + public void TryAddParticipantTower_Returns_InvalidTower_With_Broken_Component_Reason() + { + BackpackInventoryData inventory = CreateValidInventory(); + inventory.MuzzleComponents[0].Endurance = 0f; + + 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.BrokenMuzzleComponent)); + Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); + } + [Test] public void TryAddParticipantTower_Returns_AlreadyAssigned_Without_Duplicating_List() { diff --git a/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs b/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs new file mode 100644 index 0000000..7c414d4 --- /dev/null +++ b/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs @@ -0,0 +1,98 @@ +using GeometryTD.CustomComponent; +using GeometryTD.CustomUtility; +using GeometryTD.Definition; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class PlayerInventoryTowerRosterServiceTests + { + [Test] + public void ReduceTowerEndurance_Reduces_Only_Target_Towers_And_Deduplicates_Ids() + { + BackpackInventoryData inventory = CreateInventory(100f, 100f, 100f); + PlayerInventoryTowerRosterService service = CreateService(inventory, out PlayerInventoryQueryModel queryModel); + + int affectedTowerCount = service.ReduceTowerEndurance(new long[] { 90001, 90001, 90002 }, 1f); + + Assert.That(affectedTowerCount, Is.EqualTo(2)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Muzzle.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Bearing.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Base.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90002).Muzzle.Endurance, Is.EqualTo(99f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90003).Muzzle.Endurance, Is.EqualTo(100f)); + } + + [Test] + public void ReduceTowerEndurance_Clamps_Component_Endurance_To_Zero() + { + BackpackInventoryData inventory = CreateInventory(1f, 1f, 1f); + PlayerInventoryTowerRosterService service = CreateService(inventory, out PlayerInventoryQueryModel queryModel); + + int affectedTowerCount = service.ReduceTowerEndurance(new long[] { 90001 }, 5f); + + Assert.That(affectedTowerCount, Is.EqualTo(1)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Muzzle.Endurance, Is.EqualTo(0f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Bearing.Endurance, Is.EqualTo(0f)); + Assert.That(GetTowerComponents(queryModel.Inventory, 90001).Base.Endurance, Is.EqualTo(0f)); + } + + private static PlayerInventoryTowerRosterService CreateService( + BackpackInventoryData inventory, + out PlayerInventoryQueryModel queryModel) + { + PlayerInventoryState state = new PlayerInventoryState(); + queryModel = new PlayerInventoryQueryModel(state); + PlayerInventoryCommandModel commandModel = new PlayerInventoryCommandModel(state); + commandModel.Initialize(inventory, 4); + return new PlayerInventoryTowerRosterService(queryModel, 4); + } + + private static BackpackInventoryData CreateInventory( + float firstTowerEndurance, + float secondTowerEndurance, + float thirdTowerEndurance) + { + BackpackInventoryData inventory = new BackpackInventoryData(); + AddTower(inventory, 90001, 10001, 20001, 30001, firstTowerEndurance); + AddTower(inventory, 90002, 10002, 20002, 30002, secondTowerEndurance); + AddTower(inventory, 90003, 10003, 20003, 30003, thirdTowerEndurance); + return inventory; + } + + private static void AddTower( + BackpackInventoryData inventory, + long towerId, + long muzzleId, + long bearingId, + long baseId, + float endurance) + { + inventory.MuzzleComponents.Add(new MuzzleCompItemData { InstanceId = muzzleId, Endurance = endurance }); + inventory.BearingComponents.Add(new BearingCompItemData { InstanceId = bearingId, Endurance = endurance }); + inventory.BaseComponents.Add(new BaseCompItemData { InstanceId = baseId, Endurance = endurance }); + inventory.Towers.Add(new TowerItemData + { + InstanceId = towerId, + MuzzleComponentInstanceId = muzzleId, + BearingComponentInstanceId = bearingId, + BaseComponentInstanceId = baseId, + Stats = new TowerStatsData() + }); + } + + private static (MuzzleCompItemData Muzzle, BearingCompItemData Bearing, BaseCompItemData Base) GetTowerComponents( + BackpackInventoryData inventory, + long towerInstanceId) + { + Assert.That(InventoryParticipantUtility.TryGetTowerById(inventory, towerInstanceId, out TowerItemData tower), Is.True); + MuzzleCompItemData muzzle = inventory.MuzzleComponents.Find(item => item.InstanceId == tower.MuzzleComponentInstanceId); + BearingCompItemData bearing = inventory.BearingComponents.Find(item => item.InstanceId == tower.BearingComponentInstanceId); + BaseCompItemData baseComp = inventory.BaseComponents.Find(item => item.InstanceId == tower.BaseComponentInstanceId); + Assert.That(muzzle, Is.Not.Null); + Assert.That(bearing, Is.Not.Null); + Assert.That(baseComp, Is.Not.Null); + return (muzzle, bearing, baseComp); + } + } +} diff --git a/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs.meta b/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs.meta new file mode 100644 index 0000000..20e229d --- /dev/null +++ b/Assets/Tests/EditMode/PlayerInventoryTowerRosterServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d3f4e9c1b7a24a1f8e0a9db7c2e5416b +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 342f6ce..93bb711 100644 --- a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs @@ -217,6 +217,24 @@ namespace GeometryTD.Tests.EditMode Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); } + [Test] + public void ValidateCombatEntry_Returns_NoValidParticipantTower_When_AllParticipantTowers_Are_Broken() + { + BackpackInventoryData inventory = CreateCombatInventory(); + inventory.MuzzleComponents[0].Endurance = 0f; + + 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.BrokenMuzzleComponent)); + } + [Test] public void ValidateCombatEntry_Returns_CanEnter_When_At_Least_One_ParticipantTower_Is_Valid() { @@ -336,6 +354,36 @@ namespace GeometryTD.Tests.EditMode Is.EqualTo("参战区没有可出战的完整塔。\n塔 #90002 缺少底座组件。\n塔 #90003 缺少枪口组件。")); } + [Test] + public void BuildBlockedCombatDialogRawData_Lists_Broken_Component_Reasons() + { + DialogFormRawData rawData = ProcedureMainCombatEntryValidationService.BuildBlockedCombatDialogRawData( + new ProcedureMainCombatEntryValidationResult + { + BlockReason = ProcedureMainCombatEntryBlockReason.NoValidParticipantTower, + ValidationSummary = new CombatParticipantTowerValidationSummary + { + InvalidResults = new[] + { + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90002, + FailureReason = CombatParticipantTowerValidationFailureReason.BrokenBaseComponent + }, + new CombatParticipantTowerValidationResult + { + TowerInstanceId = 90003, + FailureReason = CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent + } + } + } + }); + + Assert.That( + rawData.Message, + Is.EqualTo("参战区没有可出战的完整塔。\n塔 #90002 底座组件耐久为 0,无法参战。\n塔 #90003 枪口组件耐久为 0,无法参战。")); + } + private static RunState CreateTwoNodeRun() { return RunStateFactory.Create(