S5-01 + S5-02 + S5-03

This commit is contained in:
SepComet 2026-03-11 15:36:18 +08:00
parent c019f9f527
commit 113decb414
18 changed files with 570 additions and 57 deletions

View File

@ -7,7 +7,7 @@ using UnityEngine;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal sealed class CombatRunResourceStore public sealed class CombatRunResourceStore
{ {
private readonly List<TowerStatsData> _buildTowerStatsSnapshot = new(); private readonly List<TowerStatsData> _buildTowerStatsSnapshot = new();
private readonly List<TowerItemData> _participantTowerSnapshot = new(); private readonly List<TowerItemData> _participantTowerSnapshot = new();
@ -88,6 +88,21 @@ namespace GeometryTD.CustomComponent
return snapshot; return snapshot;
} }
public IReadOnlyList<long> GetParticipantTowerInstanceIdSnapshot()
{
List<long> snapshot = new List<long>(_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) public bool TryConsumeCoin(int coin)
{ {
int requiredCoin = Mathf.Max(0, coin); int requiredCoin = Mathf.Max(0, coin);

View File

@ -1,22 +1,23 @@
using System.Collections.Generic;
using GeometryTD.Definition; using GeometryTD.Definition;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal sealed class CombatSettlementContext public sealed class CombatSettlementContext
{ {
public CombatSettlementFlags Flags { get; } = new(); public CombatSettlementFlags Flags { get; } = new();
public CombatSettlementResult Result { get; } = new(); public CombatSettlementResult Result { get; } = new();
public CombatSettlementSummary Summary { get; } = new(); public CombatSettlementSummary Summary { get; } = new();
} }
internal sealed class CombatSettlementFlags public sealed class CombatSettlementFlags
{ {
public bool ShouldOpenRewardSelection; public bool ShouldOpenRewardSelection;
public bool DidEnterRewardSelection; public bool DidEnterRewardSelection;
public bool IsCommitted; public bool IsCommitted;
} }
internal sealed class CombatSettlementResult public sealed class CombatSettlementResult
{ {
public bool DidCombatWin; public bool DidCombatWin;
public int FinalCoin; public int FinalCoin;
@ -25,17 +26,17 @@ namespace GeometryTD.CustomComponent
public int DefeatedEnemyCount; public int DefeatedEnemyCount;
public int GainedGold; public int GainedGold;
public BackpackInventoryData RewardInventory; 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 List<long> TargetTowerInstanceIds { get; } = new();
public float LowBaseHpEndurancePenaltyValue; public float EnduranceLossPerComponent;
public int AffectedTowerCount; public int AffectedTowerCount;
} }
internal sealed class CombatSettlementSummary public sealed class CombatSettlementSummary
{ {
public int DefeatedEnemyCount; public int DefeatedEnemyCount;
public int GainedGold; public int GainedGold;

View File

@ -8,14 +8,13 @@ using UnityGameFramework.Runtime;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal sealed class CombatSettlementService public sealed class CombatSettlementService
{ {
private const int RewardSelectDisplayCount = 3; private const int RewardSelectDisplayCount = 3;
private const float FullBaseHpGoldBonusRate = 0.3f; private const float FullBaseHpGoldBonusRate = 0.3f;
private const float HighBaseHpGoldBonusRate = 0.1f; private const float HighBaseHpGoldBonusRate = 0.1f;
private const float HighBaseHpThreshold = 0.8f; private const float HighBaseHpThreshold = 0.8f;
private const float MidBaseHpThreshold = 0.5f; private const float SettlementTowerEnduranceLoss = 1f;
private const float LowBaseHpTowerEndurancePenalty = 10f;
public CombatSettlementContext BuildSettlementContext( public CombatSettlementContext BuildSettlementContext(
bool didCombatWin, bool didCombatWin,
@ -33,7 +32,6 @@ namespace GeometryTD.CustomComponent
out int levelRewardGold, out int levelRewardGold,
out float bonusRate, out float bonusRate,
out int bonusGold, out int bonusGold,
out bool appliedLowBaseHpPenalty,
out shouldOpenFullBaseHpRewardSelect); out shouldOpenFullBaseHpRewardSelect);
CombatSettlementContext settlementContext = new CombatSettlementContext CombatSettlementContext settlementContext = new CombatSettlementContext
@ -48,9 +46,7 @@ namespace GeometryTD.CustomComponent
settlementContext.Result.RewardInventory = resourceStore != null settlementContext.Result.RewardInventory = resourceStore != null
? resourceStore.GetRewardInventorySnapshot() ? resourceStore.GetRewardInventorySnapshot()
: new BackpackInventoryData(); : new BackpackInventoryData();
settlementContext.Result.Penalty.ShouldApplyLowBaseHpPenalty = appliedLowBaseHpPenalty; PopulateEnduranceSettlement(settlementContext, resourceStore);
settlementContext.Result.Penalty.LowBaseHpEndurancePenaltyValue =
appliedLowBaseHpPenalty ? LowBaseHpTowerEndurancePenalty : 0f;
settlementContext.Flags.ShouldOpenRewardSelection = shouldOpenFullBaseHpRewardSelect; settlementContext.Flags.ShouldOpenRewardSelection = shouldOpenFullBaseHpRewardSelect;
settlementContext.Flags.DidEnterRewardSelection = false; settlementContext.Flags.DidEnterRewardSelection = false;
settlementContext.Summary.DefeatedEnemyCount = settlementContext.Result.DefeatedEnemyCount; settlementContext.Summary.DefeatedEnemyCount = settlementContext.Result.DefeatedEnemyCount;
@ -58,7 +54,7 @@ namespace GeometryTD.CustomComponent
settlementContext.Summary.RewardInventory = settlementContext.Result.RewardInventory; settlementContext.Summary.RewardInventory = settlementContext.Result.RewardInventory;
Log.Info( 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, currentLevel != null ? currentLevel.Id : 0,
currentBaseHp, currentBaseHp,
maxBaseHp, maxBaseHp,
@ -66,7 +62,7 @@ namespace GeometryTD.CustomComponent
bonusRate, bonusRate,
bonusGold, bonusGold,
shouldOpenFullBaseHpRewardSelect, shouldOpenFullBaseHpRewardSelect,
appliedLowBaseHpPenalty); settlementContext.Result.Endurance.TargetTowerInstanceIds.Count);
return settlementContext; return settlementContext;
} }
@ -81,7 +77,7 @@ namespace GeometryTD.CustomComponent
GameEntry.PlayerInventory?.MergeInventory(rewardInventory); GameEntry.PlayerInventory?.MergeInventory(rewardInventory);
settlementContext.Result.RewardInventory = rewardInventory; settlementContext.Result.RewardInventory = rewardInventory;
settlementContext.Summary.RewardInventory = rewardInventory; settlementContext.Summary.RewardInventory = rewardInventory;
settlementContext.Result.Penalty.AffectedTowerCount = ApplyDeferredSettlementPenalty(settlementContext); settlementContext.Result.Endurance.AffectedTowerCount = ApplyDeferredSettlementEndurance(settlementContext);
settlementContext.Flags.IsCommitted = true; settlementContext.Flags.IsCommitted = true;
} }
@ -181,7 +177,6 @@ namespace GeometryTD.CustomComponent
out int levelRewardGold, out int levelRewardGold,
out float bonusRate, out float bonusRate,
out int bonusGold, out int bonusGold,
out bool appliedLowBaseHpPenalty,
out bool shouldOpenFullBaseHpRewardSelect) out bool shouldOpenFullBaseHpRewardSelect)
{ {
currentBaseHp = Mathf.Max(0, resourceStore != null ? resourceStore.CurrentBaseHp : 0); 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; levelRewardGold = currentLevel != null ? Mathf.Max(0, currentLevel.RewardGold) : 0;
bonusRate = 0f; bonusRate = 0f;
bonusGold = 0; bonusGold = 0;
appliedLowBaseHpPenalty = false;
shouldOpenFullBaseHpRewardSelect = false; shouldOpenFullBaseHpRewardSelect = false;
if (!didCombatWin || resourceStore == null) if (!didCombatWin || resourceStore == null)
@ -214,10 +208,6 @@ namespace GeometryTD.CustomComponent
{ {
bonusRate = HighBaseHpGoldBonusRate; bonusRate = HighBaseHpGoldBonusRate;
} }
else if (hpRate < MidBaseHpThreshold)
{
appliedLowBaseHpPenalty = true;
}
} }
int goldForBonusCalculation = Mathf.Max(0, resourceStore.GainedGold) + levelRewardGold; int goldForBonusCalculation = Mathf.Max(0, resourceStore.GainedGold) + levelRewardGold;
@ -226,11 +216,40 @@ namespace GeometryTD.CustomComponent
resourceStore.AddSettlementGold(settlementGold); 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<long> 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 || if (settlementContext == null ||
!settlementContext.Result.Penalty.ShouldApplyLowBaseHpPenalty || settlementContext.Result.Endurance.EnduranceLossPerComponent <= 0f ||
settlementContext.Result.Penalty.LowBaseHpEndurancePenaltyValue <= 0f) settlementContext.Result.Endurance.TargetTowerInstanceIds.Count <= 0)
{ {
return 0; return 0;
} }
@ -241,7 +260,9 @@ namespace GeometryTD.CustomComponent
return 0; 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) private static bool TryAppendRewardComponent(BackpackInventoryData rewardInventory, TowerCompItemData selectedItem)

View File

@ -3,7 +3,7 @@ using GeometryTD.Definition;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal readonly struct EnemyDropContext public readonly struct EnemyDropContext
{ {
public EnemyDropContext(DREnemy enemy, int displayPhaseIndex, LevelThemeType themeType) public EnemyDropContext(DREnemy enemy, int displayPhaseIndex, LevelThemeType themeType)
{ {

View File

@ -8,7 +8,7 @@ using Random = UnityEngine.Random;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal sealed class EnemyDropResolver public sealed class EnemyDropResolver
{ {
private const float DropChanceBase = 0.05f; private const float DropChanceBase = 0.05f;
private const float DropChancePerPhase = 0.2f; private const float DropChancePerPhase = 0.2f;

View File

@ -2,7 +2,7 @@ using GeometryTD.Definition;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal readonly struct EnemyDropResult public readonly struct EnemyDropResult
{ {
public static EnemyDropResult Empty => new(0, 0, null); public static EnemyDropResult Empty => new(0, 0, null);

View File

@ -132,10 +132,10 @@ namespace GeometryTD.CustomComponent
out assembledTower); out assembledTower);
} }
public int ReduceAllTowerEndurance(float enduranceLoss) public int ReduceTowerEndurance(IReadOnlyList<long> towerInstanceIds, float enduranceLoss)
{ {
EnsureInitialized(); EnsureInitialized();
return _towerRosterService.ReduceAllTowerEndurance(enduranceLoss); return _towerRosterService.ReduceTowerEndurance(towerInstanceIds, enduranceLoss);
} }
private void EnsureInitialized() private void EnsureInitialized()
@ -158,4 +158,3 @@ namespace GeometryTD.CustomComponent
} }
} }
} }

View File

@ -6,7 +6,7 @@ using UnityEngine;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal struct PlayerInventoryMergeSummary public struct PlayerInventoryMergeSummary
{ {
public int GainedGold; public int GainedGold;
public int GainedMuzzleCount; public int GainedMuzzleCount;
@ -19,14 +19,14 @@ namespace GeometryTD.CustomComponent
GainedTowerCount > 0; GainedTowerCount > 0;
} }
internal sealed class PlayerInventoryState public sealed class PlayerInventoryState
{ {
public BackpackInventoryData Inventory = new BackpackInventoryData(); public BackpackInventoryData Inventory = new BackpackInventoryData();
public long NextInstanceId = 1; public long NextInstanceId = 1;
public bool IsInitialized; public bool IsInitialized;
} }
internal sealed class PlayerInventoryQueryModel public sealed class PlayerInventoryQueryModel
{ {
private readonly PlayerInventoryState _state; private readonly PlayerInventoryState _state;
@ -72,7 +72,7 @@ namespace GeometryTD.CustomComponent
} }
} }
internal sealed class PlayerInventoryCommandModel public sealed class PlayerInventoryCommandModel
{ {
private readonly PlayerInventoryState _state; private readonly PlayerInventoryState _state;

View File

@ -5,7 +5,7 @@ using UnityEngine;
namespace GeometryTD.CustomComponent namespace GeometryTD.CustomComponent
{ {
internal sealed class PlayerInventoryTowerRosterService public sealed class PlayerInventoryTowerRosterService
{ {
private readonly PlayerInventoryQueryModel _queryModel; private readonly PlayerInventoryQueryModel _queryModel;
private readonly int _maxParticipantTowerCount; private readonly int _maxParticipantTowerCount;
@ -34,11 +34,15 @@ namespace GeometryTD.CustomComponent
_maxParticipantTowerCount); _maxParticipantTowerCount);
} }
public int ReduceAllTowerEndurance(float enduranceLoss) public int ReduceTowerEndurance(IReadOnlyList<long> towerInstanceIds, float enduranceLoss)
{ {
float resolvedLoss = Mathf.Max(0f, enduranceLoss); float resolvedLoss = Mathf.Max(0f, enduranceLoss);
BackpackInventoryData inventory = _queryModel.Inventory; 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; return 0;
} }
@ -46,12 +50,19 @@ namespace GeometryTD.CustomComponent
Dictionary<long, MuzzleCompItemData> muzzleMap = BuildComponentMap(inventory.MuzzleComponents); Dictionary<long, MuzzleCompItemData> muzzleMap = BuildComponentMap(inventory.MuzzleComponents);
Dictionary<long, BearingCompItemData> bearingMap = BuildComponentMap(inventory.BearingComponents); Dictionary<long, BearingCompItemData> bearingMap = BuildComponentMap(inventory.BearingComponents);
Dictionary<long, BaseCompItemData> baseMap = BuildComponentMap(inventory.BaseComponents); Dictionary<long, BaseCompItemData> baseMap = BuildComponentMap(inventory.BaseComponents);
HashSet<long> processedTowerIds = new HashSet<long>();
int affectedCount = 0; 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]; long towerInstanceId = towerInstanceIds[i];
if (tower == null) if (towerInstanceId <= 0 || !processedTowerIds.Add(towerInstanceId))
{
continue;
}
if (!InventoryParticipantUtility.TryGetTowerById(inventory, towerInstanceId, out TowerItemData tower) ||
tower == null)
{ {
continue; continue;
} }

View File

@ -8,7 +8,10 @@ namespace GeometryTD.Definition
TowerMissing = 1, TowerMissing = 1,
MissingMuzzleComponent = 2, MissingMuzzleComponent = 2,
MissingBearingComponent = 3, MissingBearingComponent = 3,
MissingBaseComponent = 4 MissingBaseComponent = 4,
BrokenMuzzleComponent = 5,
BrokenBearingComponent = 6,
BrokenBaseComponent = 7
} }
public sealed class CombatParticipantTowerValidationResult 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 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 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 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 return new CombatParticipantTowerValidationResult
{ {
TowerInstanceId = tower.InstanceId, TowerInstanceId = tower.InstanceId,
@ -165,9 +198,13 @@ namespace GeometryTD.Definition
return false; return false;
} }
private static bool HasComponent<TComponent>(IReadOnlyList<TComponent> components, long componentInstanceId) private static bool TryGetComponentById<TComponent>(
IReadOnlyList<TComponent> components,
long componentInstanceId,
out TComponent resolvedComponent)
where TComponent : TowerCompItemData where TComponent : TowerCompItemData
{ {
resolvedComponent = null;
if (components == null || componentInstanceId <= 0) if (components == null || componentInstanceId <= 0)
{ {
return false; return false;
@ -178,6 +215,7 @@ namespace GeometryTD.Definition
TComponent component = components[i]; TComponent component = components[i];
if (component != null && component.InstanceId == componentInstanceId) if (component != null && component.InstanceId == componentInstanceId)
{ {
resolvedComponent = component;
return true; return true;
} }
} }

View File

@ -132,9 +132,15 @@ namespace GeometryTD.Procedure
return "缺少轴承组件。"; return "缺少轴承组件。";
case CombatParticipantTowerValidationFailureReason.MissingBaseComponent: case CombatParticipantTowerValidationFailureReason.MissingBaseComponent:
return "缺少底座组件。"; return "缺少底座组件。";
case CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent:
return "枪口组件耐久为 0无法参战。";
case CombatParticipantTowerValidationFailureReason.BrokenBearingComponent:
return "轴承组件耐久为 0无法参战。";
case CombatParticipantTowerValidationFailureReason.BrokenBaseComponent:
return "底座组件耐久为 0无法参战。";
default: default:
return "不满足当前参战条件。"; return "不满足当前参战条件。";
} }
} }
} }
} }

View File

@ -75,6 +75,48 @@ namespace GeometryTD.Tests.EditMode
Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); 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] [Test]
public void ValidateParticipantTowers_Splits_Valid_And_Invalid_Towers() public void ValidateParticipantTowers_Splits_Valid_And_Invalid_Towers()
{ {
@ -103,19 +145,19 @@ namespace GeometryTD.Tests.EditMode
} }
[Test] [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(); BackpackInventoryData inventory = CreateInventory();
inventory.MuzzleComponents[0].Endurance = 0f; inventory.MuzzleComponents[0].Endurance = 0f;
inventory.BearingComponents[0].Endurance = 0f;
inventory.BaseComponents[0].Endurance = 0f;
CombatParticipantTowerValidationSummary summary = CombatParticipantTowerValidationSummary summary =
CombatParticipantTowerValidationService.ValidateParticipantTowers(inventory); CombatParticipantTowerValidationService.ValidateParticipantTowers(inventory);
Assert.That(summary.HasAnyValidParticipantTower, Is.True); Assert.That(summary.HasAnyValidParticipantTower, Is.False);
Assert.That(summary.ValidTowers.Count, Is.EqualTo(1)); Assert.That(summary.ValidTowers.Count, Is.EqualTo(0));
Assert.That(summary.InvalidResults.Count, Is.EqualTo(0)); Assert.That(summary.InvalidResults.Count, Is.EqualTo(1));
Assert.That(summary.InvalidResults[0].FailureReason,
Is.EqualTo(CombatParticipantTowerValidationFailureReason.BrokenMuzzleComponent));
} }
[Test] [Test]

View File

@ -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<PlayerInventoryComponent>();
SetStaticPlayerInventory(inventoryComponent);
inventoryComponent.ReplaceInventorySnapshot(inventory);
return inventoryComponent;
}
private static void SetStaticPlayerInventory(PlayerInventoryComponent inventoryComponent)
{
FieldInfo backingField = typeof(GameEntry).GetField(
"<PlayerInventory>k__BackingField",
BindingFlags.Static | BindingFlags.NonPublic);
Assert.That(backingField, Is.Not.Null);
backingField.SetValue(null, inventoryComponent);
}
private static 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);
}
}
}

View File

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

View File

@ -51,6 +51,21 @@ namespace GeometryTD.Tests.EditMode
Assert.That(inventory.ParticipantTowerInstanceIds.Count, Is.EqualTo(0)); 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] [Test]
public void TryAddParticipantTower_Returns_AlreadyAssigned_Without_Duplicating_List() public void TryAddParticipantTower_Returns_AlreadyAssigned_Without_Duplicating_List()
{ {

View File

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

View File

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

View File

@ -217,6 +217,24 @@ namespace GeometryTD.Tests.EditMode
Is.EqualTo(CombatParticipantTowerValidationFailureReason.MissingBaseComponent)); 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] [Test]
public void ValidateCombatEntry_Returns_CanEnter_When_At_Least_One_ParticipantTower_Is_Valid() 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 缺少枪口组件。")); 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() private static RunState CreateTwoNodeRun()
{ {
return RunStateFactory.Create( return RunStateFactory.Create(