diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs index 52cfe57..b8b2b21 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatNodeComponent.cs @@ -177,11 +177,7 @@ namespace GeometryTD.CustomComponent public void StartCombat( int levelId = 0, - string runId = null, - int runSeed = 0, - int nodeId = 0, - RunNodeType nodeType = RunNodeType.None, - int sequenceIndex = -1) + RunNodeExecutionContext context = null) { if (!EnsureCombatRuntimeInitialized()) { @@ -232,11 +228,7 @@ namespace GeometryTD.CustomComponent selectedLevel, phaseList, _selectedSpawnEntriesByPhaseId, - runId, - runSeed, - nodeId, - nodeType, - sequenceIndex)) + context)) { CurrentLevel = null; return; diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs index ff72afa..e0239ed 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs @@ -69,11 +69,7 @@ namespace GeometryTD.CustomComponent DRLevel level, IReadOnlyList phases, IReadOnlyDictionary> spawnEntriesByPhaseId, - string runId = null, - int runSeed = 0, - int nodeId = 0, - RunNodeType nodeType = RunNodeType.None, - int sequenceIndex = -1) + RunNodeExecutionContext context = null) { if (!_initialized || _runtime.Entity == null) { @@ -95,11 +91,7 @@ namespace GeometryTD.CustomComponent _runtime.DidCombatWin = true; _runtime.CurrentLevel = level; - _runtime.RunId = runId; - _runtime.RunSeed = runSeed; - _runtime.NodeId = nodeId; - _runtime.NodeType = nodeType; - _runtime.SequenceIndex = sequenceIndex; + _runtime.NodeContext = context != null ? context.Clone() : null; _runtime.NextDropOrdinal = 0; _runtime.NextRewardOrdinal = 0; _runtime.CombatRunResourceStore.InitializeForCombat(level); @@ -205,8 +197,8 @@ namespace GeometryTD.CustomComponent int nextDropOrdinal = _runtime.NextDropOrdinal; EnemyDropResult result = GameEntry.InventoryGeneration.ResolveEnemyDrop( context, - _runtime.RunSeed, - _runtime.SequenceIndex, + _runtime.NodeContext != null ? _runtime.NodeContext.RunSeed : 0, + _runtime.NodeContext != null ? _runtime.NodeContext.SequenceIndex : -1, ref nextDropOrdinal); _runtime.NextDropOrdinal = nextDropOrdinal; _runtime.CombatRunResourceStore.AddEnemyDefeatedReward(result.Coin, result.Gold); diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs index 73a94fb..b20740e 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs @@ -55,11 +55,7 @@ namespace GeometryTD.CustomComponent _runtime.DidCombatWin = true; _runtime.IsCompleted = false; _runtime.NodeEnterFired = false; - _runtime.RunId = null; - _runtime.RunSeed = 0; - _runtime.NodeId = 0; - _runtime.NodeType = RunNodeType.None; - _runtime.SequenceIndex = -1; + _runtime.NodeContext = null; _runtime.NextDropOrdinal = 0; _runtime.NextRewardOrdinal = 0; } @@ -203,16 +199,20 @@ namespace GeometryTD.CustomComponent public void CompleteNormalCombatAndNotify(bool didCombatWin) { CompleteCombat(didCombatWin); + RunNodeExecutionContext nodeContext = _runtime.NodeContext; GameEntry.Event.Fire( this, NodeCompleteEventArgs.Create( - _runtime.RunId, - _runtime.NodeId, - _runtime.NodeType, - _runtime.SequenceIndex, + nodeContext?.RunId, + nodeContext?.NodeId ?? 0, + nodeContext?.NodeType ?? RunNodeType.None, + nodeContext?.SequenceIndex ?? -1, RunNodeCompletionStatus.Completed, didCombatWin, - GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); + nodeContext != null + ? nodeContext.CreateCompletionSnapshot( + GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null) + : null)); } public void CompleteFailureCombatAndNotify() @@ -222,16 +222,20 @@ namespace GeometryTD.CustomComponent CloseRewardSelectForm(); CloseDialogForm(); CompleteCombat(false); + RunNodeExecutionContext nodeContext = _runtime.NodeContext; GameEntry.Event.Fire( this, NodeCompleteEventArgs.Create( - _runtime.RunId, - _runtime.NodeId, - _runtime.NodeType, - _runtime.SequenceIndex, + nodeContext?.RunId, + nodeContext?.NodeId ?? 0, + nodeContext?.NodeType ?? RunNodeType.None, + nodeContext?.SequenceIndex ?? -1, RunNodeCompletionStatus.Exception, false, - GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); + nodeContext != null + ? nodeContext.CreateCompletionSnapshot( + GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null) + : null)); } public bool HandleStartFailure(string errorMessage) diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs index 44b3f55..159bc11 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs @@ -29,11 +29,7 @@ namespace GeometryTD.CustomComponent public bool IsCompleted { get; set; } public bool NodeEnterFired { get; set; } public CombatSettlementContext SettlementContext { get; set; } - public string RunId { get; set; } - public int RunSeed { get; set; } - public int NodeId { get; set; } - public RunNodeType NodeType { get; set; } - public int SequenceIndex { get; set; } + public RunNodeExecutionContext NodeContext { get; set; } public int NextDropOrdinal { get; set; } public int NextRewardOrdinal { get; set; } } diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs index c31683e..92987b5 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs @@ -21,8 +21,8 @@ namespace GeometryTD.CustomComponent Runtime.SettlementContext, Runtime.PhaseLoopRuntime.DisplayPhaseIndex, Coordinator.ResolveCurrentThemeType(), - Runtime.RunSeed, - Runtime.SequenceIndex, + Runtime.NodeContext != null ? Runtime.NodeContext.RunSeed : 0, + Runtime.NodeContext != null ? Runtime.NodeContext.SequenceIndex : -1, ref nextRewardOrdinal, Runtime.RewardSelectFormUseCase, Coordinator.OnFullBaseHpRewardSelected, diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRunningPhaseState.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRunningPhaseState.cs index 0721717..2a188f3 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRunningPhaseState.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRunningPhaseState.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using GeometryTD.CustomEvent; using GeometryTD.DataTable; +using GeometryTD.Procedure; using UnityGameFramework.Runtime; namespace GeometryTD.CustomComponent @@ -41,10 +42,10 @@ namespace GeometryTD.CustomComponent GameEntry.Event.Fire( Coordinator, NodeEnterEventArgs.Create( - Runtime.RunId, - Runtime.NodeId, - Runtime.NodeType, - Runtime.SequenceIndex)); + Runtime.NodeContext?.RunId, + Runtime.NodeContext?.NodeId ?? 0, + Runtime.NodeContext?.NodeType ?? RunNodeType.None, + Runtime.NodeContext?.SequenceIndex ?? -1)); } Log.Info( diff --git a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs index 9c4641a..585a966 100644 --- a/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs @@ -14,10 +14,7 @@ namespace GeometryTD.CustomComponent { public class EventNodeComponent : GameFrameworkComponent { - private string _activeRunId; - private int _activeNodeId; - private RunNodeType _activeNodeType = RunNodeType.None; - private int _activeSequenceIndex = -1; + private RunNodeExecutionContext _activeContext; private readonly List _eventItems = new List(); @@ -54,7 +51,7 @@ namespace GeometryTD.CustomComponent Log.Info("EventNodeComponent initialized with {0} events.", _eventItems.Count); } - public void StartEvent(string runId = null, int nodeId = 0, RunNodeType nodeType = RunNodeType.None, int sequenceIndex = -1) + public void StartEvent(RunNodeExecutionContext context = null) { if (!_initialized) { @@ -76,13 +73,16 @@ namespace GeometryTD.CustomComponent return; } - _activeRunId = runId; - _activeNodeId = nodeId; - _activeNodeType = nodeType; - _activeSequenceIndex = sequenceIndex; + _activeContext = context != null ? context.Clone() : null; _eventFormUseCase.SetCurrentEvent(randomEvent); GameEntry.UIRouter.OpenUI(UIFormType.EventForm); - GameEntry.Event.Fire(this, NodeEnterEventArgs.Create(runId, nodeId, nodeType, sequenceIndex)); + GameEntry.Event.Fire( + this, + NodeEnterEventArgs.Create( + _activeContext?.RunId, + _activeContext?.NodeId ?? 0, + _activeContext?.NodeType ?? RunNodeType.None, + _activeContext?.SequenceIndex ?? -1)); } public void EndEvent() @@ -91,22 +91,22 @@ namespace GeometryTD.CustomComponent GameEntry.Event.Fire( this, NodeCompleteEventArgs.Create( - _activeRunId, - _activeNodeId, - _activeNodeType, - _activeSequenceIndex, + _activeContext?.RunId, + _activeContext?.NodeId ?? 0, + _activeContext?.NodeType ?? RunNodeType.None, + _activeContext?.SequenceIndex ?? -1, RunNodeCompletionStatus.Completed, true, - GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); + _activeContext != null + ? _activeContext.CreateCompletionSnapshot( + GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null) + : null)); ClearActiveNodeContext(); } private void ClearActiveNodeContext() { - _activeRunId = null; - _activeNodeId = 0; - _activeNodeType = RunNodeType.None; - _activeSequenceIndex = -1; + _activeContext = null; } private static EventOption[] ParseOptions(string optionsRaw) diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs index 62fad47..285ff87 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs @@ -171,9 +171,9 @@ namespace GeometryTD.CustomComponent } float totalWeight = 0f; - for (int rarityIndex = 0; rarityIndex < _rarityWeightBuffer.Length; rarityIndex++) + foreach (var weight in _rarityWeightBuffer) { - totalWeight += Mathf.Max(0f, _rarityWeightBuffer[rarityIndex]); + totalWeight += Mathf.Max(0f, weight); } if (totalWeight <= 0f) diff --git a/Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs b/Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs index f5e8345..f3d873b 100644 --- a/Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs @@ -8,11 +8,7 @@ namespace GeometryTD.CustomComponent { public class ShopNodeComponent : GameFrameworkComponent { - private string _activeRunId; - private int _activeRunSeed; - private int _activeNodeId; - private RunNodeType _activeNodeType = RunNodeType.None; - private int _activeSequenceIndex = -1; + private RunNodeExecutionContext _activeContext; private ShopFormUseCase _shopFormUseCase; private bool _initialized; @@ -29,26 +25,30 @@ namespace GeometryTD.CustomComponent _initialized = true; } - public void StartShop(string runId = null, int runSeed = 0, int nodeId = 0, RunNodeType nodeType = RunNodeType.None, int sequenceIndex = -1) + public void StartShop(RunNodeExecutionContext context = null) { if (!_initialized) { OnInit(); } + int runSeed = context?.RunSeed ?? 0; + int sequenceIndex = context?.SequenceIndex ?? -1; if (_shopFormUseCase == null || !_shopFormUseCase.PrepareForOpen(runSeed, sequenceIndex)) { Log.Warning("ShopNodeComponent.StartShop() failed. Shop use case is unavailable or goods generation failed."); return; } - _activeRunId = runId; - _activeRunSeed = runSeed; - _activeNodeId = nodeId; - _activeNodeType = nodeType; - _activeSequenceIndex = sequenceIndex; + _activeContext = context != null ? context.Clone() : null; GameEntry.UIRouter.OpenUI(UIFormType.ShopForm); - GameEntry.Event.Fire(this, NodeEnterEventArgs.Create(runId, nodeId, nodeType, sequenceIndex)); + GameEntry.Event.Fire( + this, + NodeEnterEventArgs.Create( + _activeContext?.RunId, + _activeContext?.NodeId ?? 0, + _activeContext?.NodeType ?? RunNodeType.None, + _activeContext?.SequenceIndex ?? -1)); } public void EndShop() @@ -57,23 +57,22 @@ namespace GeometryTD.CustomComponent GameEntry.Event.Fire( this, NodeCompleteEventArgs.Create( - _activeRunId, - _activeNodeId, - _activeNodeType, - _activeSequenceIndex, + _activeContext?.RunId, + _activeContext?.NodeId ?? 0, + _activeContext?.NodeType ?? RunNodeType.None, + _activeContext?.SequenceIndex ?? -1, RunNodeCompletionStatus.Completed, true, - GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null)); + _activeContext != null + ? _activeContext.CreateCompletionSnapshot( + GameEntry.PlayerInventory != null ? GameEntry.PlayerInventory.GetInventorySnapshot() : null) + : null)); ClearActiveNodeContext(); } private void ClearActiveNodeContext() { - _activeRunId = null; - _activeRunSeed = 0; - _activeNodeId = 0; - _activeNodeType = RunNodeType.None; - _activeSequenceIndex = -1; + _activeContext = null; } } } diff --git a/Assets/GameMain/Scripts/Event/Game/NodeCompleteEventArgs.cs b/Assets/GameMain/Scripts/Event/Game/NodeCompleteEventArgs.cs index 248ba4d..13b82d1 100644 --- a/Assets/GameMain/Scripts/Event/Game/NodeCompleteEventArgs.cs +++ b/Assets/GameMain/Scripts/Event/Game/NodeCompleteEventArgs.cs @@ -1,7 +1,5 @@ using GameFramework; using GameFramework.Event; -using GeometryTD.CustomUtility; -using GeometryTD.Definition; using GeometryTD.Procedure; namespace GeometryTD.CustomEvent @@ -24,7 +22,7 @@ namespace GeometryTD.CustomEvent public bool CombatWon { get; private set; } - public BackpackInventoryData InventorySnapshotAfterNode { get; private set; } + public RunNodeCompletionSnapshot CompletionSnapshot { get; private set; } public NodeCompleteEventArgs() { @@ -42,7 +40,7 @@ namespace GeometryTD.CustomEvent int sequenceIndex, RunNodeCompletionStatus completionStatus, bool combatWon, - BackpackInventoryData inventorySnapshotAfterNode) + RunNodeCompletionSnapshot completionSnapshot) { var args = ReferencePool.Acquire(); args.RunId = runId; @@ -51,7 +49,7 @@ namespace GeometryTD.CustomEvent args.SequenceIndex = sequenceIndex; args.CompletionStatus = completionStatus; args.CombatWon = combatWon; - args.InventorySnapshotAfterNode = InventoryCloneUtility.CloneInventory(inventorySnapshotAfterNode); + args.CompletionSnapshot = completionSnapshot != null ? completionSnapshot.Clone() : null; return args; } @@ -64,7 +62,7 @@ namespace GeometryTD.CustomEvent SequenceIndex = -1; CompletionStatus = RunNodeCompletionStatus.None; CombatWon = false; - InventorySnapshotAfterNode = null; + CompletionSnapshot = null; } } } diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs index 5ae35dc..8f81304 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs @@ -194,23 +194,36 @@ namespace GeometryTD.Procedure args.CompletionStatus, args.CombatWon); - BackpackInventoryData snapshot = args.InventorySnapshotAfterNode; - if (snapshot == null && GameEntry.PlayerInventory != null) + RunNodeCompletionSnapshot completionSnapshot = args.CompletionSnapshot != null + ? args.CompletionSnapshot.Clone() + : _currentRunState?.CreateCurrentNodeContext()?.CreateCompletionSnapshot(null); + BackpackInventoryData inventorySnapshot = completionSnapshot?.InventorySnapshot; + if (inventorySnapshot == null && GameEntry.PlayerInventory != null) { - snapshot = GameEntry.PlayerInventory.GetInventorySnapshot(); + inventorySnapshot = GameEntry.PlayerInventory.GetInventorySnapshot(); + } + + if (completionSnapshot != null) + { + completionSnapshot.InventorySnapshot = inventorySnapshot; } ProcedureMainParticipantTowerCleanupResult cleanupResult = ProcedureMainParticipantTowerCleanupService.RemoveBrokenParticipantTowers( - snapshot, + inventorySnapshot, MaxParticipantTowerCount); if (cleanupResult.HasAnyRemovedTower && GameEntry.PlayerInventory != null) { - GameEntry.PlayerInventory.ReplaceInventorySnapshot(snapshot); + GameEntry.PlayerInventory.ReplaceInventorySnapshot(inventorySnapshot); + } + + if (completionSnapshot != null) + { + completionSnapshot.InventorySnapshot = inventorySnapshot; } ProcedureMainRunAdvanceResult advanceResult = - ProcedureMainRunFlowService.TryAdvanceRun(_currentRunState, args.CompletionStatus, snapshot); + ProcedureMainRunFlowService.TryAdvanceRun(_currentRunState, args.CompletionStatus, completionSnapshot); HandleRunAdvanceResult(advanceResult); if (cleanupResult.HasAnyRemovedTower && advanceResult != ProcedureMainRunAdvanceResult.RunCompleted) @@ -266,28 +279,20 @@ namespace GeometryTD.Procedure return; } + if (GameEntry.CombatNode.CurrentThemeType != currentNode.ThemeType) + { + GameEntry.CombatNode.OnInit(currentNode.ThemeType); + } + GameEntry.CombatNode.StartCombat( currentNode.LinkedLevelId, - _currentRunState.RunId, - _currentRunState.RunSeed, - currentNode.NodeId, - currentNode.NodeType, - currentNode.SequenceIndex); + _currentRunState.CreateCurrentNodeContext()); return; case RunNodeType.Event: - GameEntry.EventNode.StartEvent( - _currentRunState.RunId, - currentNode.NodeId, - currentNode.NodeType, - currentNode.SequenceIndex); + GameEntry.EventNode.StartEvent(_currentRunState.CreateCurrentNodeContext()); return; case RunNodeType.Shop: - GameEntry.ShopNode.StartShop( - _currentRunState.RunId, - _currentRunState.RunSeed, - currentNode.NodeId, - currentNode.NodeType, - currentNode.SequenceIndex); + GameEntry.ShopNode.StartShop(_currentRunState.CreateCurrentNodeContext()); return; default: Log.Warning("ProcedureMain.OnNodeMapNodeEnterRequested() encountered unsupported node type: {0}.", diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainRunFlowService.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainRunFlowService.cs index e0942d7..002231a 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainRunFlowService.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMainRunFlowService.cs @@ -7,9 +7,9 @@ namespace GeometryTD.Procedure public static ProcedureMainRunAdvanceResult TryAdvanceRun( RunState runState, RunNodeCompletionStatus completionStatus, - BackpackInventoryData snapshot) + RunNodeCompletionSnapshot completionSnapshot) { - if (!RunStateAdvanceService.TryCompleteCurrentNode(runState, completionStatus, snapshot)) + if (!RunStateAdvanceService.TryCompleteCurrentNode(runState, completionStatus, completionSnapshot)) { return ProcedureMainRunAdvanceResult.NoChange; } @@ -24,4 +24,4 @@ namespace GeometryTD.Procedure : ProcedureMainRunAdvanceResult.AdvancedToNextNode; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunModel.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunModel.cs index f053d51..720d46d 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunModel.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunModel.cs @@ -65,6 +65,96 @@ namespace GeometryTD.Procedure } } + [Serializable] + public sealed class RunItemState + { + public int ItemId { get; set; } + public int StackCount { get; set; } + + internal RunItemState Clone() + { + return new RunItemState + { + ItemId = ItemId, + StackCount = StackCount + }; + } + } + + [Serializable] + public sealed class RunNodeExecutionContext + { + public string RunId { get; set; } + public int RunSeed { get; set; } + public int NodeId { get; set; } + public RunNodeType NodeType { get; set; } + public int SequenceIndex { get; set; } + public LevelThemeType ThemeType { get; set; } + public int ThemeStageIndex { get; set; } + public List CurrentThemePool { get; set; } = new List(); + public List ThemeHistory { get; set; } = new List(); + public int CurrentNodeContinueChallengeLayer { get; set; } + public List RunItems { get; set; } = new List(); + + internal RunNodeExecutionContext Clone() + { + return new RunNodeExecutionContext + { + RunId = RunId, + RunSeed = RunSeed, + NodeId = NodeId, + NodeType = NodeType, + SequenceIndex = SequenceIndex, + ThemeType = ThemeType, + ThemeStageIndex = ThemeStageIndex, + CurrentThemePool = RunStateCloneUtility.CloneThemeList(CurrentThemePool), + ThemeHistory = RunStateCloneUtility.CloneThemeList(ThemeHistory), + CurrentNodeContinueChallengeLayer = CurrentNodeContinueChallengeLayer, + RunItems = RunStateCloneUtility.CloneRunItems(RunItems) + }; + } + + public RunNodeCompletionSnapshot CreateCompletionSnapshot(BackpackInventoryData inventorySnapshot) + { + return new RunNodeCompletionSnapshot + { + InventorySnapshot = InventoryCloneUtility.CloneInventory(inventorySnapshot), + ThemeType = ThemeType, + ThemeStageIndex = ThemeStageIndex, + CurrentThemePool = RunStateCloneUtility.CloneThemeList(CurrentThemePool), + ThemeHistory = RunStateCloneUtility.CloneThemeList(ThemeHistory), + CurrentNodeContinueChallengeLayer = CurrentNodeContinueChallengeLayer, + RunItems = RunStateCloneUtility.CloneRunItems(RunItems) + }; + } + } + + [Serializable] + public sealed class RunNodeCompletionSnapshot + { + public BackpackInventoryData InventorySnapshot { get; set; } + public LevelThemeType ThemeType { get; set; } + public int ThemeStageIndex { get; set; } + public List CurrentThemePool { get; set; } = new List(); + public List ThemeHistory { get; set; } = new List(); + public int CurrentNodeContinueChallengeLayer { get; set; } + public List RunItems { get; set; } = new List(); + + internal RunNodeCompletionSnapshot Clone() + { + return new RunNodeCompletionSnapshot + { + InventorySnapshot = InventoryCloneUtility.CloneInventory(InventorySnapshot), + ThemeType = ThemeType, + ThemeStageIndex = ThemeStageIndex, + CurrentThemePool = RunStateCloneUtility.CloneThemeList(CurrentThemePool), + ThemeHistory = RunStateCloneUtility.CloneThemeList(ThemeHistory), + CurrentNodeContinueChallengeLayer = CurrentNodeContinueChallengeLayer, + RunItems = RunStateCloneUtility.CloneRunItems(RunItems) + }; + } + } + [Serializable] public sealed class RunState { @@ -82,6 +172,11 @@ namespace GeometryTD.Procedure ThemeType = themeType; _nodes = nodes ?? new List(); RunInventorySnapshot = InventoryCloneUtility.CloneInventory(runInventorySnapshot); + ThemeStageIndex = 0; + CurrentThemePool = CreateDefaultThemeList(themeType); + ThemeHistory = CreateDefaultThemeList(themeType); + CurrentNodeContinueChallengeLayer = 0; + RunItems = new List(); CurrentNodeIndex = _nodes.Count > 0 ? 0 : -1; IsCompleted = _nodes.Count <= 0; } @@ -98,6 +193,16 @@ namespace GeometryTD.Procedure public BackpackInventoryData RunInventorySnapshot { get; internal set; } + public int ThemeStageIndex { get; internal set; } + + public List CurrentThemePool { get; internal set; } + + public List ThemeHistory { get; internal set; } + + public int CurrentNodeContinueChallengeLayer { get; internal set; } + + public List RunItems { get; internal set; } + public IReadOnlyList Nodes => _nodes; public int NodeCount => _nodes.Count; @@ -161,5 +266,90 @@ namespace GeometryTD.Procedure { RunInventorySnapshot = InventoryCloneUtility.CloneInventory(inventorySnapshot); } + + public RunNodeExecutionContext CreateCurrentNodeContext() + { + RunNodeState currentNode = CurrentNode; + if (currentNode == null) + { + return null; + } + + return new RunNodeExecutionContext + { + RunId = RunId, + RunSeed = RunSeed, + NodeId = currentNode.NodeId, + NodeType = currentNode.NodeType, + SequenceIndex = currentNode.SequenceIndex, + ThemeType = ThemeType, + ThemeStageIndex = ThemeStageIndex, + CurrentThemePool = RunStateCloneUtility.CloneThemeList(CurrentThemePool), + ThemeHistory = RunStateCloneUtility.CloneThemeList(ThemeHistory), + CurrentNodeContinueChallengeLayer = CurrentNodeContinueChallengeLayer, + RunItems = RunStateCloneUtility.CloneRunItems(RunItems) + }; + } + + public void ApplyCompletionSnapshot(RunNodeCompletionSnapshot completionSnapshot) + { + RunInventorySnapshot = InventoryCloneUtility.CloneInventory(completionSnapshot.InventorySnapshot); + ThemeType = completionSnapshot.ThemeType; + ThemeStageIndex = completionSnapshot.ThemeStageIndex; + CurrentThemePool = RunStateCloneUtility.CloneThemeList(completionSnapshot.CurrentThemePool); + ThemeHistory = RunStateCloneUtility.CloneThemeList(completionSnapshot.ThemeHistory); + CurrentNodeContinueChallengeLayer = completionSnapshot.CurrentNodeContinueChallengeLayer; + RunItems = RunStateCloneUtility.CloneRunItems(completionSnapshot.RunItems); + } + + private static List CreateDefaultThemeList(LevelThemeType themeType) + { + List themes = new List(); + if (themeType != LevelThemeType.None) + { + themes.Add(themeType); + } + + return themes; + } + } + + internal static class RunStateCloneUtility + { + internal static List CloneThemeList(IReadOnlyList source) + { + List cloned = new List(); + if (source == null) + { + return cloned; + } + + for (int i = 0; i < source.Count; i++) + { + cloned.Add(source[i]); + } + + return cloned; + } + + internal static List CloneRunItems(IReadOnlyList source) + { + List cloned = new List(); + if (source == null) + { + return cloned; + } + + for (int i = 0; i < source.Count; i++) + { + RunItemState item = source[i]; + if (item != null) + { + cloned.Add(item.Clone()); + } + } + + return cloned; + } } } diff --git a/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunStateAdvanceService.cs b/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunStateAdvanceService.cs index b59f2cd..bca272d 100644 --- a/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunStateAdvanceService.cs +++ b/Assets/GameMain/Scripts/Procedure/ProcedureMain/RunStateAdvanceService.cs @@ -1,5 +1,3 @@ -using GeometryTD.Definition; - namespace GeometryTD.Procedure { public static class RunStateAdvanceService @@ -7,7 +5,7 @@ namespace GeometryTD.Procedure public static bool TryCompleteCurrentNode( RunState runState, RunNodeCompletionStatus completionStatus, - BackpackInventoryData inventorySnapshotAfterNode) + RunNodeCompletionSnapshot completionSnapshot) { if (runState == null || runState.IsCompleted) { @@ -20,7 +18,7 @@ namespace GeometryTD.Procedure return false; } - runState.ReplaceInventorySnapshot(inventorySnapshotAfterNode); + runState.ApplyCompletionSnapshot(completionSnapshot); if (completionStatus == RunNodeCompletionStatus.Exception) { @@ -38,12 +36,14 @@ namespace GeometryTD.Procedure int nextIndex = runState.CurrentNodeIndex + 1; if (nextIndex >= runState.Nodes.Count) { + runState.CurrentNodeContinueChallengeLayer = 0; runState.CurrentNodeIndex = runState.Nodes.Count; runState.IsCompleted = true; return true; } runState.CurrentNodeIndex = nextIndex; + runState.CurrentNodeContinueChallengeLayer = 0; RunNodeState nextNode = runState.CurrentNode; if (nextNode != null && nextNode.Status == RunNodeStatus.Locked) { diff --git a/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs b/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs new file mode 100644 index 0000000..a83fee8 --- /dev/null +++ b/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs @@ -0,0 +1,77 @@ +using GeometryTD.Definition; +using GeometryTD.UI; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class ContinueChallengeMultiplierTests + { + [Test] + public void BuildRawData_Uses_OneX_Multiplier_For_First_Loop_And_Invalid_Input() + { + CombatInfoFormRawData firstLoop = CombatInfoFormUseCase.BuildRawData( + LevelThemeType.Plain, + levelId: 1, + currentPhaseIndex: 1, + totalPhaseCount: 4, + coin: 10, + baseHp: 20, + baseHpMax: 20, + canEnd: true); + CombatInfoFormRawData invalidInput = CombatInfoFormUseCase.BuildRawData( + LevelThemeType.Plain, + levelId: 1, + currentPhaseIndex: 0, + totalPhaseCount: 0, + coin: 10, + baseHp: 20, + baseHpMax: 20, + canEnd: false); + + Assert.That(firstLoop.EnemyHpRateMultiplier, Is.EqualTo(1)); + Assert.That(invalidInput.EnemyHpRateMultiplier, Is.EqualTo(1)); + } + + [Test] + public void BuildRawData_Doubles_EnemyHp_After_Each_Full_Loop() + { + CombatInfoFormRawData secondLoop = CombatInfoFormUseCase.BuildRawData( + LevelThemeType.Volcano, + levelId: 2, + currentPhaseIndex: 5, + totalPhaseCount: 4, + coin: 15, + baseHp: 18, + baseHpMax: 20, + canEnd: true); + CombatInfoFormRawData thirdLoop = CombatInfoFormUseCase.BuildRawData( + LevelThemeType.Mountain, + levelId: 3, + currentPhaseIndex: 9, + totalPhaseCount: 4, + coin: 20, + baseHp: 16, + baseHpMax: 20, + canEnd: true); + + Assert.That(secondLoop.EnemyHpRateMultiplier, Is.EqualTo(2)); + Assert.That(thirdLoop.EnemyHpRateMultiplier, Is.EqualTo(4)); + } + + [Test] + public void BuildRawData_Clamps_EnemyHp_Multiplier_At_IntMaxValue() + { + CombatInfoFormRawData rawData = CombatInfoFormUseCase.BuildRawData( + LevelThemeType.Plain, + levelId: 1, + currentPhaseIndex: 121, + totalPhaseCount: 4, + coin: 30, + baseHp: 10, + baseHpMax: 20, + canEnd: false); + + Assert.That(rawData.EnemyHpRateMultiplier, Is.EqualTo(int.MaxValue)); + } + } +} diff --git a/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs.meta b/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs.meta new file mode 100644 index 0000000..4bd57ab --- /dev/null +++ b/Assets/Tests/EditMode/ContinueChallengeMultiplierTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 98b9356f64b87bb4ba1d0bd65081b224 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs new file mode 100644 index 0000000..b169278 --- /dev/null +++ b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs @@ -0,0 +1,102 @@ +using GeometryTD.Definition; +using GeometryTD.Factory; +using Newtonsoft.Json.Linq; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class EventDefinitionFactoryTests + { + [Test] + public void RequirementFactory_Creates_GoldAtLeast_From_Count_Or_Gold_Field() + { + EventRequirementBase countRequirement = EventRequirementFactory.Create( + "GoldAtLeast", + JObject.Parse(@"{ ""Count"": 35 }")); + EventRequirementBase goldRequirement = EventRequirementFactory.Create( + "GoldAtLeast", + JObject.Parse(@"{ ""Gold"": 21 }")); + + Assert.That(countRequirement, Is.TypeOf()); + Assert.That(((GoldAtLeastParam)countRequirement.Param).Gold, Is.EqualTo(35)); + Assert.That(goldRequirement, Is.TypeOf()); + Assert.That(((GoldAtLeastParam)goldRequirement.Param).Gold, Is.EqualTo(21)); + } + + [Test] + public void RequirementFactory_Creates_Component_And_Relic_Requirements_With_Expected_Params() + { + EventRequirementBase compRequirement = EventRequirementFactory.Create( + "CompCountAtLeast", + JObject.Parse(@"{ ""Count"": 2, ""Rarity"": ""Blue"" }")); + EventRequirementBase relicRequirement = EventRequirementFactory.Create( + "HasRelic", + JObject.Parse(@"{ ""Id"": 9001 }")); + + Assert.That(compRequirement, Is.TypeOf()); + CompCountAtLeastParam compParam = (CompCountAtLeastParam)compRequirement.Param; + Assert.That(compParam.Count, Is.EqualTo(2)); + Assert.That(compParam.Rarity, Is.EqualTo(RarityType.Blue)); + + Assert.That(relicRequirement, Is.TypeOf()); + Assert.That(((HasRelicParam)relicRequirement.Param).RelicId, Is.EqualTo(9001)); + } + + [Test] + public void EffectFactory_Creates_AddGold_And_Component_Effects_With_Probability() + { + EventEffectBase addGoldEffect = EventEffectFactory.Create( + "AddGold", + JObject.Parse(@"{ ""Count"": 80 }"), + 0.75f); + EventEffectBase addRandomCompEffect = EventEffectFactory.Create( + "AddRandomComps", + JObject.Parse(@"{ ""Count"": 1, ""Rarity"": ""Purple"" }"), + 0.5f); + EventEffectBase removeRandomCompEffect = EventEffectFactory.Create( + "RemoveRandomComps", + JObject.Parse(@"{ ""Count"": 2, ""Rarity"": ""Blue"" }"), + 0.25f); + + Assert.That(addGoldEffect, Is.TypeOf()); + Assert.That(((AddGoldParam)addGoldEffect.Param).Count, Is.EqualTo(80)); + Assert.That(addGoldEffect.Probability, Is.EqualTo(0.75f)); + + Assert.That(addRandomCompEffect, Is.TypeOf()); + AddRandomCompsParam addParam = (AddRandomCompsParam)addRandomCompEffect.Param; + Assert.That(addParam.Count, Is.EqualTo(1)); + Assert.That(addParam.Rarity, Is.EqualTo(RarityType.Purple)); + Assert.That(addRandomCompEffect.Probability, Is.EqualTo(0.5f)); + + Assert.That(removeRandomCompEffect, Is.TypeOf()); + RemoveRandomCompsParam removeParam = (RemoveRandomCompsParam)removeRandomCompEffect.Param; + Assert.That(removeParam.Count, Is.EqualTo(2)); + Assert.That(removeParam.Rarity, Is.EqualTo(RarityType.Blue)); + Assert.That(removeRandomCompEffect.Probability, Is.EqualTo(0.25f)); + } + + [Test] + public void EffectFactory_Creates_DamageRandomTowerEnduranceEffect_With_Expected_Params() + { + EventEffectBase effect = EventEffectFactory.Create( + "DamageRandomTowersEndurance", + JObject.Parse(@"{ ""Count"": 3, ""Amount"": 15 }"), + 0.4f); + + Assert.That(effect, Is.TypeOf()); + DamageRandomTowerEnduranceParam param = (DamageRandomTowerEnduranceParam)effect.Param; + Assert.That(param.Count, Is.EqualTo(3)); + Assert.That(param.Amount, Is.EqualTo(15)); + Assert.That(effect.Probability, Is.EqualTo(0.4f)); + } + + [Test] + public void Factories_Return_Null_For_Unsupported_Or_Empty_Type() + { + Assert.That(EventRequirementFactory.Create(string.Empty, JObject.Parse("{}")), Is.Null); + Assert.That(EventRequirementFactory.Create("UnknownRequirement", JObject.Parse("{}")), Is.Null); + Assert.That(EventEffectFactory.Create(string.Empty, JObject.Parse("{}")), Is.Null); + Assert.That(EventEffectFactory.Create("UnknownEffect", JObject.Parse("{}")), Is.Null); + } + } +} diff --git a/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs.meta b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs.meta new file mode 100644 index 0000000..093bbe6 --- /dev/null +++ b/Assets/Tests/EditMode/EventDefinitionFactoryTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 822dbd88944ebf24faf09b2acd38a1a2 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Tests/EditMode/GeometryTD.Tests.EditMode.asmdef b/Assets/Tests/EditMode/GeometryTD.Tests.EditMode.asmdef index b3f8154..ccc0abe 100644 --- a/Assets/Tests/EditMode/GeometryTD.Tests.EditMode.asmdef +++ b/Assets/Tests/EditMode/GeometryTD.Tests.EditMode.asmdef @@ -3,7 +3,9 @@ "rootNamespace": "GeometryTD.Tests.EditMode", "references": [ "GeometryTD.Runtime", - "UnityGameFramework.Runtime" + "UnityGameFramework.Runtime", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" ], "includePlatforms": [ "Editor" @@ -12,13 +14,14 @@ "allowUnsafeCode": false, "overrideReferences": true, "precompiledReferences": [ - "GameFramework.dll" + "GameFramework.dll", + "nunit.framework.dll", + "Newtonsoft.Json.dll" ], "autoReferenced": false, - "defineConstraints": [], + "defineConstraints": [ + "UNITY_INCLUDE_TESTS" + ], "versionDefines": [], - "noEngineReferences": false, - "optionalUnityReferences": [ - "TestAssemblies" - ] -} + "noEngineReferences": false +} \ No newline at end of file diff --git a/Assets/Tests/EditMode/ProcedureMainFlowTests.cs b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs index bc0a1bd..c105338 100644 --- a/Assets/Tests/EditMode/ProcedureMainFlowTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainFlowTests.cs @@ -98,7 +98,7 @@ namespace GeometryTD.Tests.EditMode 0, RunNodeCompletionStatus.Completed, true, - new BackpackInventoryData { Gold = 77 })); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 77 }))); Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1)); @@ -133,7 +133,7 @@ namespace GeometryTD.Tests.EditMode 0, RunNodeCompletionStatus.Exception, false, - new BackpackInventoryData { Gold = 12 })); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 12 }))); Assert.That(GetFlowPhase(procedure), Is.EqualTo(ProcedureMainFlowPhase.Hub)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); @@ -165,7 +165,7 @@ namespace GeometryTD.Tests.EditMode 0, RunNodeCompletionStatus.Completed, true, - brokenSnapshot)); + CreateCompletionSnapshot(brokenSnapshot))); BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot(); DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; @@ -180,7 +180,8 @@ namespace GeometryTD.Tests.EditMode } [Test] - public void OnNodeComplete_Boss_Run_Completed_Shows_Run_Complete_Dialog_And_Does_Not_Override_It_With_Cleanup_Dialog() + public void + OnNodeComplete_Boss_Run_Completed_Shows_Run_Complete_Dialog_And_Does_Not_Override_It_With_Cleanup_Dialog() { RecorderUIRouter recorderRouter = CreateBoundUIRouter(); PlayerInventoryComponent inventoryComponent = CreateBoundPlayerInventory(CreateParticipantInventory(100f)); @@ -207,7 +208,7 @@ namespace GeometryTD.Tests.EditMode 0, RunNodeCompletionStatus.Completed, true, - brokenSnapshot)); + CreateCompletionSnapshot(brokenSnapshot))); BackpackInventoryData replacedInventory = inventoryComponent.GetInventorySnapshot(); DialogFormRawData dialogRawData = recorderRouter.DialogController.LastOpenedUserData as DialogFormRawData; @@ -220,7 +221,8 @@ namespace GeometryTD.Tests.EditMode Assert.That(recorderRouter.DialogController.OpenCount, Is.EqualTo(1)); Assert.That(dialogRawData, Is.Not.Null); Assert.That(dialogRawData.Title, Is.EqualTo("Run Complete")); - Assert.That(dialogRawData.Message, Is.EqualTo("Boss node completed. This run is finished and will return to the main menu.")); + Assert.That(dialogRawData.Message, + Is.EqualTo("Boss node completed. This run is finished and will return to the main menu.")); Assert.That(dialogRawData.OnClickConfirm, Is.Not.Null); dialogRawData.OnClickConfirm(null); @@ -428,7 +430,8 @@ namespace GeometryTD.Tests.EditMode new BackpackInventoryData { Gold = 50 }, new[] { - new RunNodeSeed { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1, SequenceIndex = 0 }, + new RunNodeSeed + { NodeId = 101, NodeType = RunNodeType.Combat, LinkedLevelId = 1, SequenceIndex = 0 }, new RunNodeSeed { NodeId = 102, NodeType = RunNodeType.Event, SequenceIndex = 1 } }, "testrun"); @@ -467,6 +470,20 @@ namespace GeometryTD.Tests.EditMode return inventory; } + private static RunNodeCompletionSnapshot CreateCompletionSnapshot(BackpackInventoryData inventorySnapshot) + { + return new RunNodeCompletionSnapshot + { + InventorySnapshot = inventorySnapshot, + ThemeType = LevelThemeType.Plain, + ThemeStageIndex = 0, + CurrentThemePool = new List { LevelThemeType.Plain }, + ThemeHistory = new List { LevelThemeType.Plain }, + CurrentNodeContinueChallengeLayer = 0, + RunItems = new List() + }; + } + private sealed class RecorderUIRouter { private readonly Dictionary _controllers = new(); diff --git a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs index d1f7c9a..c644af4 100644 --- a/Assets/Tests/EditMode/ProcedureMainServicesTests.cs +++ b/Assets/Tests/EditMode/ProcedureMainServicesTests.cs @@ -16,7 +16,7 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 88 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 88 }, continueChallengeLayer: 4)); Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.AdvancedToNextNode)); Assert.That(runState.IsCompleted, Is.False); @@ -24,6 +24,7 @@ namespace GeometryTD.Tests.EditMode Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1)); Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(88)); + Assert.That(runState.CurrentNodeContinueChallengeLayer, Is.EqualTo(0)); } [Test] @@ -34,7 +35,7 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun( runState, RunNodeCompletionStatus.Exception, - new BackpackInventoryData { Gold = 5 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 5 }, continueChallengeLayer: 2)); Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.NodeException)); Assert.That(runState.IsCompleted, Is.False); @@ -42,6 +43,7 @@ namespace GeometryTD.Tests.EditMode Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception)); Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Locked)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5)); + Assert.That(runState.CurrentNodeContinueChallengeLayer, Is.EqualTo(2)); } [Test] @@ -58,7 +60,7 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 123 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 123 })); Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.RunCompleted)); Assert.That(runState.IsCompleted, Is.True); @@ -73,7 +75,7 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult nullRunResult = ProcedureMainRunFlowService.TryAdvanceRun( null, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 1 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 1 })); Assert.That(nullRunResult, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange)); @@ -85,7 +87,7 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult completedRunResult = ProcedureMainRunFlowService.TryAdvanceRun( completedRun, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 20 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 20 })); Assert.That(completedRunResult, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange)); Assert.That(completedRun.RunInventorySnapshot.Gold, Is.EqualTo(10)); @@ -99,12 +101,13 @@ namespace GeometryTD.Tests.EditMode ProcedureMainRunAdvanceResult result = ProcedureMainRunFlowService.TryAdvanceRun( runState, RunNodeCompletionStatus.None, - new BackpackInventoryData { Gold = 999 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 999 }, themeStageIndex: 4)); Assert.That(result, Is.EqualTo(ProcedureMainRunAdvanceResult.NoChange)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Available)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(999)); + Assert.That(runState.ThemeStageIndex, Is.EqualTo(4)); } [Test] @@ -462,5 +465,31 @@ namespace GeometryTD.Tests.EditMode inventory.ParticipantTowerInstanceIds.Add(90001); return inventory; } + + private static RunNodeCompletionSnapshot CreateCompletionSnapshot( + BackpackInventoryData inventorySnapshot, + LevelThemeType themeType = LevelThemeType.Plain, + int themeStageIndex = 0, + int continueChallengeLayer = 0) + { + RunNodeCompletionSnapshot snapshot = new RunNodeCompletionSnapshot + { + InventorySnapshot = inventorySnapshot, + ThemeType = themeType, + ThemeStageIndex = themeStageIndex, + CurrentThemePool = new System.Collections.Generic.List(), + ThemeHistory = new System.Collections.Generic.List(), + CurrentNodeContinueChallengeLayer = continueChallengeLayer, + RunItems = new System.Collections.Generic.List() + }; + + if (themeType != LevelThemeType.None) + { + snapshot.CurrentThemePool.Add(themeType); + snapshot.ThemeHistory.Add(themeType); + } + + return snapshot; + } } } diff --git a/Assets/Tests/EditMode/RunStateTests.cs b/Assets/Tests/EditMode/RunStateTests.cs index 080643a..6155c15 100644 --- a/Assets/Tests/EditMode/RunStateTests.cs +++ b/Assets/Tests/EditMode/RunStateTests.cs @@ -38,6 +38,11 @@ namespace GeometryTD.Tests.EditMode Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Available)); Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Locked)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(120)); + Assert.That(runState.ThemeStageIndex, Is.EqualTo(0)); + Assert.That(runState.CurrentThemePool, Is.EqualTo(new[] { LevelThemeType.Plain })); + Assert.That(runState.ThemeHistory, Is.EqualTo(new[] { LevelThemeType.Plain })); + Assert.That(runState.CurrentNodeContinueChallengeLayer, Is.EqualTo(0)); + Assert.That(runState.RunItems, Is.Empty); sourceInventory.Gold = 1; sourceInventory.ParticipantTowerInstanceIds[0] = 99; @@ -62,22 +67,30 @@ namespace GeometryTD.Tests.EditMode bool firstCompleted = RunStateAdvanceService.TryCompleteCurrentNode( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 80 }); + CreateCompletionSnapshot( + new BackpackInventoryData { Gold = 80 }, + continueChallengeLayer: 3, + runItemId: 1001, + runItemStackCount: 2)); Assert.That(firstCompleted, Is.True); Assert.That(runState.Nodes[0].Status, Is.EqualTo(RunNodeStatus.Completed)); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(1)); Assert.That(runState.Nodes[1].Status, Is.EqualTo(RunNodeStatus.Available)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(80)); + Assert.That(runState.CurrentNodeContinueChallengeLayer, Is.EqualTo(0)); + Assert.That(runState.RunItems.Count, Is.EqualTo(1)); + Assert.That(runState.RunItems[0].ItemId, Is.EqualTo(1001)); + Assert.That(runState.RunItems[0].StackCount, Is.EqualTo(2)); RunStateAdvanceService.TryCompleteCurrentNode( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 95 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 95 })); RunStateAdvanceService.TryCompleteCurrentNode( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 130 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 130 })); Assert.That(runState.IsCompleted, Is.True); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(3)); @@ -124,19 +137,26 @@ namespace GeometryTD.Tests.EditMode bool result = RunStateAdvanceService.TryCompleteCurrentNode( runState, RunNodeCompletionStatus.Exception, - new BackpackInventoryData { Gold = 5 }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 5 }, continueChallengeLayer: 4)); Assert.That(result, Is.True); Assert.That(runState.IsCompleted, Is.False); Assert.That(runState.CurrentNodeIndex, Is.EqualTo(0)); Assert.That(runState.CurrentNode.Status, Is.EqualTo(RunNodeStatus.Exception)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(5)); + Assert.That(runState.CurrentNodeContinueChallengeLayer, Is.EqualTo(4)); } [Test] - public void NodeCompleteEventArgs_Completed_Win_Clones_Inventory_And_Clears_State() + public void NodeCompleteEventArgs_Completed_Win_Clones_CompletionSnapshot_And_Clears_State() { - BackpackInventoryData inventory = new BackpackInventoryData { Gold = 66 }; + RunNodeCompletionSnapshot completionSnapshot = CreateCompletionSnapshot( + new BackpackInventoryData { Gold = 66 }, + themeType: LevelThemeType.Volcano, + themeStageIndex: 2, + continueChallengeLayer: 5, + runItemId: 901, + runItemStackCount: 3); NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( "run-1", 7, @@ -144,9 +164,11 @@ namespace GeometryTD.Tests.EditMode 3, RunNodeCompletionStatus.Completed, true, - inventory); + completionSnapshot); - inventory.Gold = 1; + completionSnapshot.InventorySnapshot.Gold = 1; + completionSnapshot.CurrentThemePool[0] = LevelThemeType.Mountain; + completionSnapshot.RunItems[0].StackCount = 99; Assert.That(eventArgs.RunId, Is.EqualTo("run-1")); Assert.That(eventArgs.NodeId, Is.EqualTo(7)); @@ -154,7 +176,11 @@ namespace GeometryTD.Tests.EditMode Assert.That(eventArgs.SequenceIndex, Is.EqualTo(3)); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Completed)); Assert.That(eventArgs.CombatWon, Is.True); - Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(66)); + Assert.That(eventArgs.CompletionSnapshot.InventorySnapshot.Gold, Is.EqualTo(66)); + Assert.That(eventArgs.CompletionSnapshot.ThemeType, Is.EqualTo(LevelThemeType.Volcano)); + Assert.That(eventArgs.CompletionSnapshot.ThemeStageIndex, Is.EqualTo(2)); + Assert.That(eventArgs.CompletionSnapshot.CurrentThemePool, Is.EqualTo(new[] { LevelThemeType.Volcano })); + Assert.That(eventArgs.CompletionSnapshot.RunItems[0].StackCount, Is.EqualTo(3)); eventArgs.Clear(); @@ -164,13 +190,13 @@ namespace GeometryTD.Tests.EditMode Assert.That(eventArgs.SequenceIndex, Is.EqualTo(-1)); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.None)); Assert.That(eventArgs.CombatWon, Is.False); - Assert.That(eventArgs.InventorySnapshotAfterNode, Is.Null); + Assert.That(eventArgs.CompletionSnapshot, Is.Null); } [Test] public void NodeCompleteEventArgs_Completed_Loss_Preserves_NonWin_Semantics() { - BackpackInventoryData inventory = new BackpackInventoryData { Gold = 12 }; + RunNodeCompletionSnapshot completionSnapshot = CreateCompletionSnapshot(new BackpackInventoryData { Gold = 12 }); NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( "run-2", 8, @@ -178,9 +204,9 @@ namespace GeometryTD.Tests.EditMode 4, RunNodeCompletionStatus.Completed, false, - inventory); + completionSnapshot); - inventory.Gold = 99; + completionSnapshot.InventorySnapshot.Gold = 99; Assert.That(eventArgs.RunId, Is.EqualTo("run-2")); Assert.That(eventArgs.NodeId, Is.EqualTo(8)); @@ -188,13 +214,13 @@ namespace GeometryTD.Tests.EditMode Assert.That(eventArgs.SequenceIndex, Is.EqualTo(4)); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Completed)); Assert.That(eventArgs.CombatWon, Is.False); - Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(12)); + Assert.That(eventArgs.CompletionSnapshot.InventorySnapshot.Gold, Is.EqualTo(12)); } [Test] public void NodeCompleteEventArgs_Exception_Preserves_Exception_Semantics() { - BackpackInventoryData inventory = new BackpackInventoryData { Gold = 7 }; + RunNodeCompletionSnapshot completionSnapshot = CreateCompletionSnapshot(new BackpackInventoryData { Gold = 7 }); NodeCompleteEventArgs eventArgs = NodeCompleteEventArgs.Create( "run-3", 9, @@ -202,9 +228,9 @@ namespace GeometryTD.Tests.EditMode 5, RunNodeCompletionStatus.Exception, false, - inventory); + completionSnapshot); - inventory.Gold = 100; + completionSnapshot.InventorySnapshot.Gold = 100; Assert.That(eventArgs.RunId, Is.EqualTo("run-3")); Assert.That(eventArgs.NodeId, Is.EqualTo(9)); @@ -212,7 +238,54 @@ namespace GeometryTD.Tests.EditMode Assert.That(eventArgs.SequenceIndex, Is.EqualTo(5)); Assert.That(eventArgs.CompletionStatus, Is.EqualTo(RunNodeCompletionStatus.Exception)); Assert.That(eventArgs.CombatWon, Is.False); - Assert.That(eventArgs.InventorySnapshotAfterNode.Gold, Is.EqualTo(7)); + Assert.That(eventArgs.CompletionSnapshot.InventorySnapshot.Gold, Is.EqualTo(7)); + } + + [Test] + public void CreateCurrentNodeContext_Clones_Run_State_Collections() + { + RunState runState = RunStateFactory.Create( + LevelThemeType.Plain, + new BackpackInventoryData { Gold = 20 }, + new[] + { + new RunNodeSeed { NodeId = 99, NodeType = RunNodeType.Combat, LinkedLevelId = 1 }, + new RunNodeSeed { NodeId = 100, NodeType = RunNodeType.Event } + }); + + RunNodeCompletionSnapshot seededSnapshot = new RunNodeCompletionSnapshot + { + InventorySnapshot = new BackpackInventoryData { Gold = 35 }, + ThemeType = LevelThemeType.Mountain, + ThemeStageIndex = 3, + CurrentThemePool = new List { LevelThemeType.Mountain, LevelThemeType.Volcano }, + ThemeHistory = new List { LevelThemeType.Plain, LevelThemeType.Mountain }, + CurrentNodeContinueChallengeLayer = 6, + RunItems = new List + { + new RunItemState { ItemId = 2001, StackCount = 4 } + } + }; + RunStateAdvanceService.TryCompleteCurrentNode( + runState, + RunNodeCompletionStatus.Completed, + seededSnapshot); + runState.ApplyCompletionSnapshot(seededSnapshot); + + RunNodeExecutionContext context = runState.CreateCurrentNodeContext(); + + runState.CurrentThemePool[0] = LevelThemeType.Plain; + runState.ThemeHistory[0] = LevelThemeType.Volcano; + runState.RunItems[0].StackCount = 1; + + Assert.That(context.RunId, Is.EqualTo(runState.RunId)); + Assert.That(context.NodeId, Is.EqualTo(100)); + Assert.That(context.ThemeStageIndex, Is.EqualTo(3)); + Assert.That(context.CurrentThemePool, Is.EqualTo(new[] { LevelThemeType.Mountain, LevelThemeType.Volcano })); + Assert.That(context.ThemeHistory, Is.EqualTo(new[] { LevelThemeType.Plain, LevelThemeType.Mountain })); + Assert.That(context.CurrentNodeContinueChallengeLayer, Is.EqualTo(6)); + Assert.That(context.RunItems[0].ItemId, Is.EqualTo(2001)); + Assert.That(context.RunItems[0].StackCount, Is.EqualTo(4)); } [Test] @@ -275,7 +348,7 @@ namespace GeometryTD.Tests.EditMode bool advanced = RunStateAdvanceService.TryCompleteCurrentNode( runState, RunNodeCompletionStatus.Completed, - new BackpackInventoryData { Gold = 100 + i }); + CreateCompletionSnapshot(new BackpackInventoryData { Gold = 100 + i })); Assert.That(advanced, Is.True); } @@ -285,5 +358,42 @@ namespace GeometryTD.Tests.EditMode Assert.That(runState.CurrentNodeIndex, Is.EqualTo(10)); Assert.That(runState.RunInventorySnapshot.Gold, Is.EqualTo(109)); } + + private static RunNodeCompletionSnapshot CreateCompletionSnapshot( + BackpackInventoryData inventorySnapshot, + LevelThemeType themeType = LevelThemeType.Plain, + int themeStageIndex = 0, + int continueChallengeLayer = 0, + int runItemId = 0, + int runItemStackCount = 0) + { + RunNodeCompletionSnapshot snapshot = new RunNodeCompletionSnapshot + { + InventorySnapshot = inventorySnapshot, + ThemeType = themeType, + ThemeStageIndex = themeStageIndex, + CurrentThemePool = new List(), + ThemeHistory = new List(), + CurrentNodeContinueChallengeLayer = continueChallengeLayer, + RunItems = new List() + }; + + if (themeType != LevelThemeType.None) + { + snapshot.CurrentThemePool.Add(themeType); + snapshot.ThemeHistory.Add(themeType); + } + + if (runItemId > 0) + { + snapshot.RunItems.Add(new RunItemState + { + ItemId = runItemId, + StackCount = runItemStackCount + }); + } + + return snapshot; + } } } diff --git a/Assets/Tests/EditMode/ShopPricingRuleTests.cs b/Assets/Tests/EditMode/ShopPricingRuleTests.cs new file mode 100644 index 0000000..7c68b7b --- /dev/null +++ b/Assets/Tests/EditMode/ShopPricingRuleTests.cs @@ -0,0 +1,295 @@ +using System.Collections; +using System; +using System.Collections.Generic; +using System.Reflection; +using GameFramework.DataTable; +using GeometryTD.CustomComponent; +using GeometryTD.DataTable; +using GeometryTD.Definition; +using GeometryTD.UI; +using NUnit.Framework; + +namespace GeometryTD.Tests.EditMode +{ + public sealed class ShopPricingRuleTests + { + [Test] + public void BuildGoods_Uses_Price_Row_Range_Matching_Item_Rarity() + { + ShopGoodsBuilder builder = CreateBuilder( + CreateShopPriceRow(1, RarityType.Blue, 30, 35), + CreateShopPriceRow(2, RarityType.Purple, 60, 70)); + + List goods = builder.BuildGoods(goodsCount: 12, runSeed: 3001, sequenceIndex: 4); + + Assert.That(goods, Has.Count.EqualTo(12)); + for (int i = 0; i < goods.Count; i++) + { + GoodsItemRawData item = goods[i]; + Assert.That(item, Is.Not.Null); + Assert.That(item.SourceItem, Is.Not.Null); + + switch (item.SourceItem.Rarity) + { + case RarityType.Blue: + Assert.That(item.Price, Is.InRange(30, 35)); + break; + case RarityType.Purple: + Assert.That(item.Price, Is.InRange(60, 70)); + break; + default: + Assert.Fail($"Unexpected rarity generated for goods item {i}: {item.SourceItem.Rarity}"); + break; + } + } + } + + [Test] + public void BuildGoods_Is_Reproducible_For_Same_RunSeed_And_SequenceIndex() + { + ShopGoodsBuilder builder = CreateBuilder( + CreateShopPriceRow(1, RarityType.Blue, 30, 35), + CreateShopPriceRow(2, RarityType.Purple, 60, 70)); + + string first = BuildGoodsSignature(builder.BuildGoods(goodsCount: 4, runSeed: 901, sequenceIndex: 7)); + string second = BuildGoodsSignature(builder.BuildGoods(goodsCount: 4, runSeed: 901, sequenceIndex: 7)); + + Assert.That(second, Is.EqualTo(first)); + } + + [Test] + public void ResolveRandomPrice_Returns_Zero_When_Rarity_Has_No_Price_Row() + { + ShopGoodsBuilder builder = CreateBuilder(CreateShopPriceRow(1, RarityType.Blue, 30, 35)); + MethodInfo method = typeof(ShopGoodsBuilder).GetMethod( + "ResolveRandomPrice", + BindingFlags.Instance | BindingFlags.NonPublic); + + Assert.That(method, Is.Not.Null); + + int resolvedPrice = (int)method.Invoke(builder, new object[] { RarityType.Red, new Random(7) }); + + Assert.That(resolvedPrice, Is.EqualTo(0)); + } + + private static ShopGoodsBuilder CreateBuilder(params DRShopPrice[] shopPriceRows) + { + return new ShopGoodsBuilder( + shopPriceRows, + new FakeDataTable(CreateMuzzleRow(1, "火焰枪口")), + new FakeDataTable(CreateBearingRow(1, "寒冰轴承")), + new FakeDataTable(CreateBaseRow(1, "迅捷底座"))); + } + + private static string BuildGoodsSignature(IReadOnlyList goods) + { + List parts = new List(goods.Count); + for (int i = 0; i < goods.Count; i++) + { + GoodsItemRawData item = goods[i]; + parts.Add($"{item.GoodsIndex}:{item.Price}:{item.SourceItem.SlotType}:{item.SourceItem.Rarity}:{item.SourceItem.ConfigId}"); + } + + return string.Join("|", parts); + } + + private static DRShopPrice CreateShopPriceRow(int id, RarityType rarity, int minPrice, int maxPrice) + { + DRShopPrice row = new DRShopPrice(); + Assert.That(row.ParseDataRow($"\t{id}\t\t{rarity}\t{minPrice}\t{maxPrice}", null), Is.True); + return row; + } + + private static DRMuzzleComp CreateMuzzleRow(int id, string name) + { + DRMuzzleComp row = new DRMuzzleComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[10,20,30,40,50]\t3\t0.15\tNormalBullet\t\t[Fire,Crit]", null), + Is.True); + return row; + } + + private static DRBearingComp CreateBearingRow(int id, string name) + { + DRBearingComp row = new DRBearingComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[1,2,3,4,5]\t0.5\t[10,20,30,40,50]\t1\t\t[Ice,Shatter]", null), + Is.True); + return row; + } + + private static DRBaseComp CreateBaseRow(int id, string name) + { + DRBaseComp row = new DRBaseComp(); + Assert.That( + row.ParseDataRow($"\t{id}\t\t{name}\t[2,4,6,8,10]\t-0.25\tFire\t\t[Fire,Crit]", null), + Is.True); + return row; + } + + private sealed class FakeDataTable : IDataTable where TRow : class, IDataRow + { + private readonly Dictionary _rowsById = new(); + + public FakeDataTable(params TRow[] rows) + { + if (rows == null) + { + return; + } + + for (int i = 0; i < rows.Length; i++) + { + TRow row = rows[i]; + if (row != null) + { + _rowsById[row.Id] = row; + } + } + } + + public string Name => typeof(TRow).Name; + public string FullName => typeof(TRow).FullName; + public Type Type => typeof(TRow); + public int Count => _rowsById.Count; + public TRow this[int id] => GetDataRow(id); + public TRow MinIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[0]); + public TRow MaxIdDataRow => _rowsById.Count <= 0 ? null : GetDataRow(GetOrderedIds()[^1]); + + public bool HasDataRow(int id) => _rowsById.ContainsKey(id); + public bool HasDataRow(Predicate condition) => GetDataRow(condition) != null; + public TRow GetDataRow(int id) => _rowsById.TryGetValue(id, out TRow row) ? row : null; + + public TRow GetDataRow(Predicate condition) + { + if (condition == null) + { + return null; + } + + foreach (TRow row in _rowsById.Values) + { + if (row != null && condition(row)) + { + return row; + } + } + + return null; + } + + public TRow[] GetDataRows(Predicate condition) + { + List results = new(); + GetDataRows(condition, results); + return results.ToArray(); + } + + public void GetDataRows(Predicate condition, List results) + { + results?.Clear(); + if (condition == null || results == null) + { + return; + } + + foreach (TRow row in _rowsById.Values) + { + if (row != null && condition(row)) + { + results.Add(row); + } + } + } + + public TRow[] GetDataRows(Comparison comparison) + { + List results = new(); + GetDataRows(comparison, results); + return results.ToArray(); + } + + public void GetDataRows(Comparison comparison, List results) + { + results?.Clear(); + if (results == null) + { + return; + } + + results.AddRange(_rowsById.Values); + if (comparison != null) + { + results.Sort(comparison); + } + } + + public TRow[] GetDataRows(Predicate condition, Comparison comparison) + { + List results = new(); + GetDataRows(condition, comparison, results); + return results.ToArray(); + } + + public void GetDataRows(Predicate condition, Comparison comparison, List results) + { + GetDataRows(condition, results); + if (results != null && comparison != null) + { + results.Sort(comparison); + } + } + + public TRow[] GetAllDataRows() + { + List results = new(); + GetAllDataRows(results); + return results.ToArray(); + } + + public void GetAllDataRows(List results) + { + results?.Clear(); + if (results == null) + { + return; + } + + foreach (int id in GetOrderedIds()) + { + results.Add(_rowsById[id]); + } + } + + public bool AddDataRow(string dataRowString, object userData) => throw new NotSupportedException(); + public bool AddDataRow(byte[] dataRowBytes, int startIndex, int length, object userData) => throw new NotSupportedException(); + public bool RemoveDataRow(int id) => _rowsById.Remove(id); + + public void RemoveAllDataRows() + { + _rowsById.Clear(); + } + + public IEnumerator GetEnumerator() + { + foreach (int id in GetOrderedIds()) + { + yield return _rowsById[id]; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private int[] GetOrderedIds() + { + int[] ids = new int[_rowsById.Count]; + _rowsById.Keys.CopyTo(ids, 0); + Array.Sort(ids); + return ids; + } + } + } +} diff --git a/Assets/Tests/EditMode/ShopPricingRuleTests.cs.meta b/Assets/Tests/EditMode/ShopPricingRuleTests.cs.meta new file mode 100644 index 0000000..612ab16 --- /dev/null +++ b/Assets/Tests/EditMode/ShopPricingRuleTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d8e3a4a971efff9458007b36792c9fb1 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/docs/CodeX-TODO.md b/docs/CodeX-TODO.md index fa16d5d..b1157ff 100644 --- a/docs/CodeX-TODO.md +++ b/docs/CodeX-TODO.md @@ -1,240 +1,251 @@ -# CodeX TODO +# CodeX M2 执行计划 -最后更新:2026-03-13 +> 依据:`docs/TODO.md` 当前口径,默认 `M1` 已完成并已收口。 +> 目标:把 `M2(P1)- 核心深度` 拆成可直接开工的执行顺序、具体工作包与验收边界。 -> 目标:把当前“随机组件产出 / 掉落 / 商店 / 3 选 1 奖励候选 / Tag 初始化入口”从分散实现收口成清晰模块。 -> 原则:先收运行时入口,再收重复领域逻辑,最后收 UI 编排;不把纯规则层误做成 `GameFrameworkComponent`。 +## 当前代码现状判断 -## 这次重构要解决的问题 +- `EventNodeComponent`、`DREvent`、`EventForm` 已有最小骨架,`Event.txt` 已有 3 个样例事件,但 `EventFormUseCase.SelectOption()` 仍停留在 `TODO`,尚未真正执行条件校验、概率结算、消耗/奖励效果。 +- `ShopNodeComponent` 与 `ShopFormUseCase` 已支持“生成 4 个商品并购买组件”的最小链路,但还没有出售、复杂定价、耐久折价、卖塔加成,也没有把商店能力沉到统一规则入口。 +- `RunState` 已完成 A1 扩展,当前已承载 `runId / runSeed / current theme / theme stage / current theme pool / theme history / current node continue challenge layer / run items / node sequence / inventory snapshot`,并且事件、商店、战斗都已经改为通过统一节点完成快照回写这些状态。 +- `LevelThemeType` 已有 `Volcano / Mountain`,战斗侧已有按主题取关卡、掉落按主题偏置的基础入口,因此主题玩法更适合在现有战斗调度器上继续补钩子,而不是重做主流程。 -- 商店、敌人掉落、满血 3 选 1 候选都在各自生成组件实例,逻辑分散,后续改规则要改多处。 -- `ShopFormUseCase`、旧 `EnemyDropResolver`、`CombatSettlementService` 都曾同时承担“流程 + 规则 + 数据组装”的混合职责。 -- 重构前 `runSeed` 只稳定了 Tag 生成,没有稳定整个组件产出流程;当前已补齐到商店、掉落、奖励候选与奖励展示顺序。 -- Tag 模块本身已经分成 `Generation / Aggregation / Combat / Metadata / Presentation`,但初始化入口还散在流程代码里。 +## 当前进度更新 -## 重构边界 +- `A1` 已完成:`RunState` 长期状态字段、`RunNodeExecutionContext`、`RunNodeCompletionSnapshot`、`ProcedureMain` 统一推进写回链路都已落地。 +- `A2` 已完成:已经补上三组最小 `EditMode` 测试入口,分别覆盖事件定义工厂、商店价格规则入口、当前可见的继续挑战倍率公式。 +- 当前下一步应直接进入 `P1-01`,收口事件执行链路,不再继续停留在基础设施阶段。 -### 适合抽成 `GameFrameworkComponent` 的模块 +## M2 总体推进顺序 -1. `InventoryGenerationComponent` - - 统一作为运行时“组件产出入口”。 - - 对外提供: - - `BuildShopGoods(...)` - - `ResolveEnemyDrop(...)` - - `BuildRewardCandidates(...)` - - 统一持有并传递 `runSeed`、`sequenceIndex`、随机上下文。 +1. 先收口事件系统 `P1-01 ~ P1-02`,因为代码骨架已经在,投入最小,能最快形成第二类节点的真实玩法。 +2. 再收口商店系统 `P1-03 ~ P1-04`,把“买/卖/定价”统一到同一套库存与价格规则中。 +3. 之后补运行中长期状态承载:道具系统 `P1-05` 与继续挑战 `P1-06`。 +4. 再做主题玩法底座与两个主题规则 `P1-07 ~ P1-09`,避免火山、山地各自临时写一套。 +5. 最后做大关后主题选择 `P1-10`,因为它依赖新的 run 分段结构与主题状态流转。 -2. `TagRegistryComponent` - - 统一作为 Tag 规则注册与重载入口。 - - 负责刷新: - - `TagDefinitionRegistry` - - `TagGenerationRuleRegistry` - - `RarityTagBudgetRuleRegistry` - - 替代旧的 `ProcedurePreload` 直连刷新逻辑,并收口当前主流程初始化入口。 +## 开工前置 -### 不适合抽成 `GameFrameworkComponent` 的模块 +### A1. 先补 M2 状态承载 -- `ComponentTagGenerationService` -- `TowerTagAggregationService` -- `TagEffectResolver` -- `NumericTagEffectHandler` -- `AttackShapeTagEffectHandler` -- `EnemyStatusTagRegistry` +- 当前状态:已完成。 +- 目标:在不破坏 M1 固定 10 节点闭环的前提下,为 M2 新功能预留明确状态字段。 +- 交付物: + - `Assets/GameMain/Scripts/Procedure/ProcedureMain/RunModel.cs` + - `Assets/GameMain/Scripts/Procedure/ProcedureMain/` 下相关推进服务 +- 具体工作: + - 给 `RunState` 增加继续挑战层数、主题阶段索引、当前主题池、运行中道具列表或等价结构。 + - 明确哪些状态属于 `RunState`,哪些属于 `PlayerInventory`,避免“金币在 run、道具在 inventory、主题选择在 UI 临时变量”这种分散写法。 + - 为节点完成事件补足 M2 需要的快照字段,保证事件/商店/战斗都能通过统一推进入口回写状态。 +- 验收补充: + - 不影响现有 M1 测试。 + - 新字段默认值明确,未启用 M2 功能时不会改变 M1 行为。 -这些都保留为普通领域模块,因为它们本质上是规则计算、数据聚合或战斗解析,不需要全局生命周期。 +### A2. 为 M2 先补最小测试底座 -## 目标模块结构 +- 当前状态:已完成。 +- 目标:先把容易回归的规则放进 `EditMode`,避免事件和定价后面越改越散。 +- 交付物: + - `Assets/Tests/EditMode/` +- 具体工作: + - 先建三组测试入口:事件需求/效果执行、商店定价、继续挑战倍率。 + - 对主题玩法先补纯规则测试,场景表现与路径交互后续再补 PlayMode。 +- 验收补充: + - 至少保证新增核心公式与随机种子行为可回归。 -### 组件层 +## P1 详细计划 -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs` -- `Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs` +### P1-01 事件节点系统(选项、概率、奖励/惩罚执行器) -### 领域模块层 +- 核心目标:把现有“能打开事件 UI”补完成“能稳定结算并推进节点”的正式系统。 +- 交付物: + - `Assets/GameMain/Scripts/CustomComponent/EventNodeComponent.cs` + - `Assets/GameMain/Scripts/UI/Game/UseCase/EventFormUseCase.cs` + - `Assets/GameMain/Scripts/Definition/Event/` + - 必要时新增 `Assets/GameMain/Scripts/Procedure/` 下的事件结算服务 +- 具体拆分: + - 增加统一事件执行入口:`检查 Requirements -> 执行 CostEffects -> 掷概率 -> 执行 RewardEffects -> 生成结算结果 -> EndEvent`。 + - 概率结算改为基于 `runSeed + sequenceIndex + eventId + optionIndex` 的稳定随机,避免同局回放不一致。 + - Requirements 不满足时,UI 侧需要明确禁止点击或提示原因,不能点了再静默失败。 + - Effects 不要散落在 UI 控制器里,统一走一个事件执行服务或等价显式流程函数。 + - 明确失败语义:要求不足属于“不可选”,概率失败属于“已执行但收益为空或惩罚生效”。 +- 验收补充: + - 可稳定配置并执行至少 10 个事件。 + - 事件结束后金币、库存、耐久等变化能正确写回节点完成快照。 + - 同一种子重复运行时,事件随机结果一致。 -- `Assets/GameMain/Scripts/Factory/ComponentItemFactory.cs` - - 唯一负责“从配置行构造 `TowerCompItemData`”。 -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs` - - 统一承载商店 / 掉落 / 奖励候选的随机合同。 -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs` - - 只负责商店货物生成。 -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs` - - 只负责掉落池筛选、权重抽样、稀有度曲线。 -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs` - - 只负责 3 选 1 奖励候选生成。 -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatSettlementCalculator.cs` - - 只负责结算计算。 -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatSettlementCommitter.cs` - - 只负责把结算结果提交到玩家库存。 +### P1-02 落地 3 个示例事件(赌马 / 组件交换 / 耐久换金币) -## 现有职责迁移目标 +- 核心目标:用 3 个真实事件验证 `P1-01` 的执行器不是一次性硬编码。 +- 交付物: + - `Assets/GameMain/DataTables/Event.txt` + - 若现有效果不足,补 `Assets/GameMain/Scripts/Definition/Event/EventEffect/` + - 若现有条件不足,补 `Assets/GameMain/Scripts/Definition/Event/EventRequirement/` +- 具体拆分: + - 保留并收口“赌马”事件,验证 `GoldAtLeast + AddGold + Probability`。 + - 把“组件交换”改成真正符合设计的链路:支付指定品质组件,返回不低于原品质的新组件;现有“换金币”样例不足以覆盖该目标。 + - 新增“耐久换金币”事件,复用当前耐久体系,明确扣减对象、扣减数量、损坏后的处理与节点结束后的清理。 + - 事件表扩充到 10 个可用事件时,先按低风险/中风险/功能型三类分层,避免全是纯金币事件。 +- 验收补充: + - 三个示例事件在局内都能完整触发、结算、回写状态。 + - 不会出现组件被扣掉但奖励未发、或耐久扣成负值这种半成功状态。 -### 商店链路 +### P1-03 商店节点:购买 / 出售组件 -- `ShopNodeComponent` - - 保留“节点进入 / 退出 / 发事件 / 打开 UI”职责。 -- `ShopFormUseCase` - - 删除组件随机生成职责。 - - 保留“当前货物模型、购买行为、UI RawData 组装”职责。 -- 商店货物来源统一改为 `GameEntry.InventoryGeneration.BuildShopGoods(...)`。 +- 核心目标:把当前“仅购买组件”的商店补成完整交易节点。 +- 交付物: + - `Assets/GameMain/Scripts/UI/Shop/` + - `Assets/GameMain/Scripts/CustomComponent/ShopNodeComponent.cs` + - 必要时新增 `Assets/GameMain/Scripts/Utility/` 下的交易规则工具 +- 具体拆分: + - Shop UI 增加玩家库存展示与出售入口,至少支持出售三类组件。 + - 交易结算统一走一个买卖接口,不要在 UI 层分别修改金币和库存。 + - 商品购买后状态、库存变化、金币变化要同时刷新,不允许 UI 还显示可买但实际已买。 + - 明确商店节点退出条件与节点完成时机,避免出现开店即完成或出售后不回流的问题。 +- 验收补充: + - 买卖后库存与金币实时正确更新。 + - 关闭商店后重新打开同一节点时,商品状态与本节点交易结果一致。 -### 战斗掉落链路 +### P1-04 商店定价规则:买价、半价回收、卖塔 +10%、耐久折价 -- `CombatScheduler` - - 不再直接持有复杂掉落生成细节。 - - 只在敌人死亡时调用 `GameEntry.InventoryGeneration.ResolveEnemyDrop(...)`。 -- 旧 `EnemyDropResolver` - - 已从主链路移除,并已删除旧实现文件。 - - 其职责已拆给: - - `DropPoolRoller` - - `ComponentItemFactory` - - `RewardCandidateBuilder` +- 核心目标:把价格从“按品质随机区间”升级为可解释、可测试的公式。 +- 交付物: + - `Assets/GameMain/Scripts/Utility/` + - `Assets/GameMain/Scripts/Entity/` 或库存相关域对象 + - 视需要调整 `Assets/GameMain/DataTables/ShopPrice.txt` +- 具体拆分: + - 抽出统一价格解析入口:基础买价、出售价、塔出售加成、耐久折价都走同一公式服务。 + - 明确“组件价格”和“整塔价格”关系,不能在 UI 里临时把三组件价格相加后再乘一个魔法数。 + - 明确耐久折价规则:按当前耐久比例折价,还是按损坏阈值分段折价;先写死规则文档,再落代码。 + - 如果 `ShopPrice.txt` 只够表达品质区间,新增字段或新表承载倍率,不要继续在代码里硬编码。 +- 验收补充: + - 买价、回收半价、卖塔 +10%、耐久折价都可通过测试直接验证。 + - 同类物品在同一规则下价格可解释,不出现随机漂移。 -### 战斗结算链路 +### P1-05 道具系统(影响初始金币、掉金倍率等) -- `CombatSettlementService` - - 从混合类拆成薄流程壳。 - - 保留最少流程编排。 - - 计算逻辑迁到 `CombatSettlementCalculator`。 - - 库存提交逻辑迁到 `CombatSettlementCommitter`。 - - 奖励候选生成改为调用 `GameEntry.InventoryGeneration.BuildRewardCandidates(...)`。 +- 核心目标:引入 run 内长期生效的被动效果,支撑中期构筑深度。 +- 交付物: + - `Assets/GameMain/Scripts/Entity/` + - `Assets/GameMain/DataTables/*.txt` + - `Assets/GameMain/Scripts/Procedure/ProcedureMain/` +- 具体拆分: + - 先定义“道具”最小模型:唯一 Id、名称、描述、叠加规则、效果列表。 + - 道具效果统一挂在 run 状态,不要把“初始金币加成”写进开局逻辑、“掉金倍率”写进敌人掉落逻辑、“商店折扣”又写进商店 UI。 + - 第一批只做能复用现有链路的效果:初始金币、掉金倍率、商店折扣、额外奖励候选、事件收益倍率。 + - 明确获取来源:先只接战斗奖励或事件奖励,暂不同时开启商店售卖与局外解锁。 +- 验收补充: + - 至少 5 个道具可叠加生效。 + - 多个道具同时存在时,叠加顺序和公式固定且可测试。 -### Tag 初始化链路 +### P1-06 战斗后“继续挑战”机制(高强度高爆率) -- `ProcedurePreload` - - 移除 Tag 注册表刷新细节。 - - 只保留资源与数据表预加载职责。 -- `ProcedureMain` - - 进入主流程时主动调用 `GameEntry.TagRegistry.OnInit()`。 -- `TagRegistryComponent` - - 成为 Tag 相关表与注册表的唯一运行时入口。 +- 核心目标:把当前战斗结算从单向结束改成“收手 / 继续”的风险收益选择。 +- 交付物: + - `Assets/GameMain/Scripts/Procedure/` + - `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/` + - 需要时补 `Assets/GameMain/Scripts/UI/Combat/` 或现有结算 UI +- 具体拆分: + - 战斗胜利后新增继续挑战分支,不改变失败结算语义。 + - 给 run 增加当前节点继续挑战层数,并把它传入敌人强度和掉落倍率计算。 + - 强度提升与爆率提升必须都走显式公式,且支持按层数递增。 + - 继续挑战退出时,节点奖励如何结算、是否覆盖原奖励、是否保底,需要在实现前先写清楚。 +- 验收补充: + - 选择继续后敌人强度明显提升且爆率提升。 + - 连续多次继续时,奖励和风险曲线都可感知,不出现“白打”。 -## 分阶段执行 +### P1-07 火山主题规则(高温、岩浆格、抗火敌人) -## 阶段 G1 - 收口组件产出入口 +- 核心目标:做出第一个真实改变战斗结果的主题规则,而不是只换关卡皮肤。 +- 交付物: + - `Assets/GameMain/Scripts/Scene/` + - `Assets/GameMain/Scripts/Entity/` + - 必要时新增主题规则配置表 +- 具体拆分: + - 先抽主题战斗规则入口,统一在战斗调度或关卡运行时挂载主题效果。 + - 高温规则先定义为全局环境修正;岩浆格是地图局部修正;敌人抗火是单位侧修正,三者不要混成一个大脚本。 + - 岩浆格必须有清晰表现和可命中逻辑,不能只有视觉特效。 + - 与现有 Tag / 属性体系冲突处优先显式列出,不在第一版做复杂联动。 +- 验收补充: + - 岩浆效果可视,且确实会改变塔或敌人的战斗结果。 -| 状态 | ID | 任务 | 目标 | -|-----|-------|-----------------------------------|------------| -| [x] | G1-01 | 新增 `InventoryGenerationComponent` | 建立统一组件产出入口 | -| [x] | G1-02 | 新增 `ComponentItemFactory` | 收口组件实例构造 | -| [x] | G1-03 | 新增 `ShopGoodsBuilder` | 收口商店货物生成 | -| [x] | G1-04 | 让 `ShopFormUseCase` 改用组件入口 | 商店不再自己生成组件 | +### P1-08 山地主题规则(高度、悬崖、位移致死) -### G1 验收标准 +- 核心目标:做出第二个与火山玩法差异明显的主题。 +- 交付物: + - `Assets/GameMain/Scripts/Scene/` + - `Assets/GameMain/Scripts/Entity/` + - 必要时新增高度或地形标记配置 +- 具体拆分: + - 为地图格子补高度/悬崖属性,而不是把高度写死在某一张图脚本里。 + - 塔攻击高打低、低打高的距离或命中修正单独实现,避免和基础攻击范围直接耦死。 + - 敌人上下坡移速修正与悬崖击退致死分开结算,便于后续加其他位移效果。 + - 先支持对现有敌人和击退能力最小接入,不追求一次做完整地形系统。 +- 验收补充: + - 高低地形会影响攻防与移速。 + - 悬崖击退击杀能真实生效并参与结算。 -- 商店仍可正常打开、购买、加背包。 -- 商店不再直接读取组件表并构造 `Muzzle/Bearing/Base` 实例。 -- 商店所有组件实例都通过同一工厂生成。 +### P1-09 主题地图掉落偏好(按主题偏置组件) -## 阶段 G2 - 收口战斗掉落与奖励候选 +- 核心目标:让主题不只影响战斗规则,也影响构筑倾向。 +- 交付物: + - `Assets/GameMain/DataTables/*.txt` + - `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/` +- 具体拆分: + - 在现有 `OutGameDropPool` 或新增偏置表中表达主题掉落权重,不要把火山/山地偏置硬编码在掉落代码里。 + - 主题偏置只影响权重,不直接写死掉落结果,保留随机性。 + - 明确偏置作用对象:组件槽位、品质、Tag、或其组合,第一版建议先偏置组件/Tag。 +- 验收补充: + - 不同主题统计掉落分布显著不同。 + - 偏置结果与 `P1-07 / P1-08` 的主题反制方向一致。 -| 状态 | ID | 任务 | 目标 | -|-----|-------|----------------------------------------|---------------| -| [x] | G2-01 | 新增 `DropPoolRoller` | 收口掉落池与稀有度抽样 | -| [x] | G2-02 | 新增 `RewardCandidateBuilder` | 收口 3 选 1 候选生成 | -| [x] | G2-03 | 让战斗掉落改走 `InventoryGenerationComponent` | 战斗不再自己构造组件 | -| [x] | G2-04 | 让满血奖励候选改走统一入口 | 掉落与奖励候选共用产出体系 | +### P1-10 大关完成后可选下一主题地图 -### G2 验收标准 +- 核心目标:把当前“固定单主题 10 节点 run”扩成“按大关推进并选择下个主题”。 +- 交付物: + - `Assets/GameMain/Scripts/Procedure/` + - `Assets/GameMain/Scripts/UI/Templates/GameScene/` 或现有节点地图相关 UI +- 具体拆分: + - 先定义“大关”与“节点序列”的关系:是每个大关 10 节点,还是一个 run 内包含多个主题段。 + - Run 流程需要新增“主题选择阶段”,不能继续只靠 `CurrentNodeIndex++` 线性推进。 + - 主题选择 UI 至少展示 2 个候选主题、主题效果摘要、预期掉落倾向。 + - 选择后要能重建后续节点池、关卡池和掉落偏置,而不是只改一个 `ThemeType` 字段。 +- 验收补充: + - 通关后至少可在 2 个主题间选择。 + - 主题选择会真实影响后续关卡、掉落和规则。 -- 敌人死亡后仍能正常掉 coin、gold、组件。 -- 满血奖励 3 选 1 仍能正常打开并选择。 -- 掉落与奖励候选不再重复维护组件实例构造逻辑。 -- `CombatScheduler` 与 `CombatSettlementService` 已改为统一调用 `GameEntry.InventoryGeneration`。 +## 推荐分批提交 -## 阶段 G3 - 收口结算模块边界 +### 批次 1:事件系统收口 -| 状态 | ID | 任务 | 目标 | -|-----|-------|---------------------------------|-----------------| -| [x] | G3-01 | 新增 `CombatSettlementCalculator` | 只保留结算计算 | -| [x] | G3-02 | 新增 `CombatSettlementCommitter` | 只保留库存提交 | -| [x] | G3-03 | 精简 `CombatSettlementService` | 只剩流程编排 | -| [x] | G3-04 | 清理奖励 RawData 重复映射 | 候选生成与 UI 组装边界清晰 | +- `A1` 中与事件相关的状态补充 +- `P1-01` +- `P1-02` -### G3 验收标准 +### 批次 2:商店系统收口 -- 战斗结算金币、奖励库存、耐久扣减结果保持不变。 -- 满血奖励选择后仍能正确并包。 -- `CombatSettlementService` 不再同时承担计算、提交、候选生成三类职责。 +- `P1-03` +- `P1-04` -## 阶段 G4 - 收口 Tag 初始化入口 +### 批次 3:run 深度状态 -| 状态 | ID | 任务 | 目标 | -|-----|-------|----------------------------------|--------------------| -| [x] | G4-01 | 新增 `TagRegistryComponent` | 建立 Tag 运行时入口 | -| [x] | G4-02 | 挪出 `ProcedurePreload` 中 Tag 注册逻辑 | 流程代码不再直接维护 Tag 注册表 | -| [x] | G4-03 | 对齐 Tag 加载时机与重载入口 | 初始化路径明确 | +- `P1-05` +- `P1-06` -### G4 验收标准 +### 批次 4:主题玩法底座与内容 -- `Tag.txt`、`TagConfig.txt`、`RarityTagBudget.txt` 加载后仍能正确刷新运行时。 -- Tag 展示、Tag 生成、Tag 战斗效果不受影响。 -- Tag 相关运行时入口不再散落在预加载流程里。 +- 主题规则统一入口 +- `P1-07` +- `P1-08` +- `P1-09` -## 阶段 G5 - 收口随机合同 +### 批次 5:多主题推进 -| 状态 | ID | 任务 | 目标 | -|-----|-------|------------------------|---------------------| -| [x] | G5-01 | 统一商店 / 掉落 / 奖励候选的随机上下文 | 全部产出链路使用一致合同 | -| [x] | G5-02 | 补齐整体产出的 `runSeed` 稳定性 | 不只稳定 Tag,也稳定物品产出 | -| [x] | G5-03 | 增加对应 EditMode 测试 | 同 Run 可复现,跨 Run 可区分 | +- `P1-10` -### G5 验收标准 +## 当前建议的第一开工项 -- 同一 `runSeed + sequenceIndex` 下,商店货物、掉落候选、奖励候选可复现。 -- 不同 `runSeed` 下,产出结果可区分。 -- 不再出现“Tag 稳定但物品本体不稳定”的混合口径。 - -## 具体迁移清单 - -### 第一批直接改动文件 - -- `Assets/GameMain/Scripts/UI/Shop/UseCase/ShopFormUseCase.cs` -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs` -- `Assets/GameMain/Scripts/Procedure/Base/ProcedurePreload.cs` -- `Assets/GameMain/Scripts/Procedure/ProcedureMain/ProcedureMain.cs` -- `Assets/GameMain/Scripts/Base/GameEntry.Custom.cs` -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs` - -### 第二批新增文件 - -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs` -- `Assets/GameMain/Scripts/CustomComponent/TagRegistry/TagRegistryComponent.cs` -- `Assets/GameMain/Scripts/Factory/ComponentItemFactory.cs` -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationRandomContext.cs` -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/ShopGoodsBuilder.cs` -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs` -- `Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/RewardCandidateBuilder.cs` -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatSettlementCalculator.cs` -- `Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatSettlementCommitter.cs` - -## 本次重构的限制 - -- 不在这一轮扩展新的 Tag 功能。 -- 不在这一轮扩展新的商店玩法或新的奖励 UI 形态。 -- 不为了“统一”而把所有领域逻辑都塞进 `GameFrameworkComponent`。 -- 不改动现有 UI 五层结构以外的职责边界。 - -## 当前推荐执行顺序 - -1. 先做 `InventoryGenerationComponent + ComponentItemFactory`。 -2. 先替换商店货物生成。 -3. 再替换战斗掉落与奖励候选生成。 -4. 再拆战斗结算计算与提交。 -5. 最后收 `TagRegistryComponent` 与主流程中的 Tag 初始化入口。 - -## 通过标准 - -- 组件实例生成入口只保留一套。 -- 掉落、商店、奖励候选不再各自维护相似逻辑。 -- Tag 模块保留现有分层,不再继续和流程代码缠在一起。 -- `GameFrameworkComponent` 只承接运行时入口,不承接纯规则实现。 - -## 当前结果 - -- `InventoryGenerationComponent` 已成为商店、战斗掉落、奖励候选的统一运行时入口。 -- 掉落概率规则与掉落物品构造已继续下沉到 `OutGameDropRuleService` 与 `OutGameDropItemBuilder`,`InventoryGenerationComponent` 保持入口编排职责。 -- `TagRegistryComponent` 已成为 Tag 运行时入口,并由 `ProcedureMain` 主动初始化。 -- `TagRegistryComponent` 已改为 fail-fast 初始化,缺少必要数据表时会直接暴露错误。 -- `InventoryGenerationRandomContext` 已统一商店、掉落、奖励候选的随机合同,并补齐稳定临时实例 Id。 -- `Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs` 已覆盖 G5 的可复现性验收点。 +1. 立刻收口 `P1-01`,把 `EventFormUseCase.SelectOption()` 从占位实现改成正式结算。 +2. 基于 A2 里已经补好的事件定义测试入口,新增事件执行链路测试,覆盖 requirement、cost、probability、reward 和完成快照回写。 +3. 用 `P1-02` 的三个示例事件验证执行器能力,再扩表到 10 个事件。 +4. 之后再进入商店买卖与定价,不建议现在同时并行推进主题地图。