diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs index 8f4060a..ff72afa 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatScheduler.cs @@ -100,7 +100,8 @@ namespace GeometryTD.CustomComponent _runtime.NodeId = nodeId; _runtime.NodeType = nodeType; _runtime.SequenceIndex = sequenceIndex; - GameEntry.InventoryGeneration.ConfigureRunContext(runSeed, sequenceIndex); + _runtime.NextDropOrdinal = 0; + _runtime.NextRewardOrdinal = 0; _runtime.CombatRunResourceStore.InitializeForCombat(level); for (int i = 0; i < phases.Count; i++) { @@ -201,7 +202,13 @@ namespace GeometryTD.CustomComponent enemy, _runtime.PhaseLoopRuntime.DisplayPhaseIndex, _coordinator.ResolveCurrentThemeType()); - EnemyDropResult result = GameEntry.InventoryGeneration.ResolveEnemyDrop(context); + int nextDropOrdinal = _runtime.NextDropOrdinal; + EnemyDropResult result = GameEntry.InventoryGeneration.ResolveEnemyDrop( + context, + _runtime.RunSeed, + _runtime.SequenceIndex, + ref nextDropOrdinal); + _runtime.NextDropOrdinal = nextDropOrdinal; _runtime.CombatRunResourceStore.AddEnemyDefeatedReward(result.Coin, result.Gold); _runtime.CombatRunResourceStore.AddEnemyDefeatedLoot(result.LootItem); } diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs index f3157b7..73a94fb 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerCoordinator.cs @@ -60,6 +60,8 @@ namespace GeometryTD.CustomComponent _runtime.NodeId = 0; _runtime.NodeType = RunNodeType.None; _runtime.SequenceIndex = -1; + _runtime.NextDropOrdinal = 0; + _runtime.NextRewardOrdinal = 0; } public void CleanupAllCombatEntities() diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs index e97a315..44b3f55 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSchedulerRuntime.cs @@ -34,5 +34,7 @@ namespace GeometryTD.CustomComponent public int NodeId { get; set; } public RunNodeType NodeType { get; set; } public int SequenceIndex { get; set; } + public int NextDropOrdinal { get; set; } + public int NextRewardOrdinal { get; set; } } } diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs index fffbde2..a3f64de 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatSettlementService.cs @@ -36,6 +36,9 @@ namespace GeometryTD.CustomComponent CombatSettlementContext settlementContext, int displayPhaseIndex, LevelThemeType themeType, + int runSeed, + int sequenceIndex, + ref int nextRewardOrdinal, RewardSelectFormUseCase rewardSelectFormUseCase, Action onRewardSelected, Action onGiveUp) @@ -48,7 +51,10 @@ namespace GeometryTD.CustomComponent IReadOnlyList candidateItems = GameEntry.InventoryGeneration.BuildRewardCandidates( displayPhaseIndex, themeType, - RewardSelectDisplayCount); + RewardSelectDisplayCount, + runSeed, + sequenceIndex, + ref nextRewardOrdinal); if (candidateItems == null || candidateItems.Count <= 0) { settlementContext.Flags.ShouldOpenRewardSelection = false; @@ -60,7 +66,7 @@ namespace GeometryTD.CustomComponent candidateItems, displayCount: RewardSelectDisplayCount, refreshCost: 0, - allowRefreshOnce: false, + allowRotateOnce: false, allowGiveUp: false, tipText: "基地满血奖励:请选择 1 个组件"); diff --git a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs index c85e061..c31683e 100644 --- a/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs +++ b/Assets/GameMain/Scripts/CustomComponent/CombatNode/CombatScheduler/CombatStates/CombatRewardSelectionState.cs @@ -16,16 +16,23 @@ namespace GeometryTD.CustomComponent } Coordinator.EnsureRewardSelectFormUseCaseBound(); + int nextRewardOrdinal = Runtime.NextRewardOrdinal; if (!Runtime.CombatSettlementService.TryPrepareRewardSelection( Runtime.SettlementContext, Runtime.PhaseLoopRuntime.DisplayPhaseIndex, Coordinator.ResolveCurrentThemeType(), + Runtime.RunSeed, + Runtime.SequenceIndex, + ref nextRewardOrdinal, Runtime.RewardSelectFormUseCase, Coordinator.OnFullBaseHpRewardSelected, Coordinator.OnFullBaseHpRewardGiveUp)) { Coordinator.ChangeState(new CombatFinishFormState(Runtime, Coordinator)); + return; } + + Runtime.NextRewardOrdinal = nextRewardOrdinal; } public override void OnExit() diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs index 73d0e8a..62fad47 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/DropPoolRoller.cs @@ -10,9 +10,17 @@ namespace GeometryTD.CustomComponent public sealed class DropPoolRoller { private const float RarityCurveScalePhase = 30f; + private static readonly RarityType[] OrderedRarities = + { + RarityType.White, + RarityType.Green, + RarityType.Blue, + RarityType.Purple, + RarityType.Red + }; private readonly List _eligibleRowBuffer = new(); - private readonly Dictionary _rarityWeightBuffer = new(); + private readonly float[] _rarityWeightBuffer = new float[OrderedRarities.Length]; private readonly IDataTable _dropPoolTable; public DropPoolRoller(IDataTable dropPoolTable) @@ -50,9 +58,8 @@ namespace GeometryTD.CustomComponent int totalWeight = 0; DROutGameDropPool fallbackRow = null; - for (int i = 0; i < _eligibleRowBuffer.Count; i++) + foreach (var row in _eligibleRowBuffer) { - DROutGameDropPool row = _eligibleRowBuffer[i]; if (!IsEligibleAtPhase(row, selectedRarity, displayPhaseIndex)) { continue; @@ -107,9 +114,8 @@ namespace GeometryTD.CustomComponent { _eligibleRowBuffer.Clear(); - for (int i = 0; i < allRows.Length; i++) + foreach (var row in allRows) { - DROutGameDropPool row = allRows[i]; if (row == null) { continue; @@ -131,15 +137,18 @@ namespace GeometryTD.CustomComponent private RarityType RollRarity(int displayPhaseIndex, Random random) { - _rarityWeightBuffer.Clear(); + for (int i = 0; i < _rarityWeightBuffer.Length; i++) + { + _rarityWeightBuffer[i] = 0f; + } + float phaseT = Mathf.Clamp01((displayPhaseIndex - 1) / RarityCurveScalePhase); - for (int i = 0; i < _eligibleRowBuffer.Count; i++) + foreach (var row in _eligibleRowBuffer) { - DROutGameDropPool row = _eligibleRowBuffer[i]; - for (int rarityIndex = (int)RarityType.White; rarityIndex <= (int)RarityType.Red; rarityIndex++) + for (int rarityIndex = 0; rarityIndex < OrderedRarities.Length; rarityIndex++) { - RarityType rarity = (RarityType)rarityIndex; + RarityType rarity = OrderedRarities[rarityIndex]; if (!IsEligibleAtPhase(row, rarity, displayPhaseIndex)) { continue; @@ -157,22 +166,14 @@ namespace GeometryTD.CustomComponent continue; } - float rarityWeight = rowWeight * curveWeight; - if (_rarityWeightBuffer.TryGetValue(rarity, out float existingWeight)) - { - _rarityWeightBuffer[rarity] = existingWeight + rarityWeight; - } - else - { - _rarityWeightBuffer[rarity] = rarityWeight; - } + _rarityWeightBuffer[rarityIndex] += rowWeight * curveWeight; } } float totalWeight = 0f; - foreach (var pair in _rarityWeightBuffer) + for (int rarityIndex = 0; rarityIndex < _rarityWeightBuffer.Length; rarityIndex++) { - totalWeight += Mathf.Max(0f, pair.Value); + totalWeight += Mathf.Max(0f, _rarityWeightBuffer[rarityIndex]); } if (totalWeight <= 0f) @@ -182,18 +183,21 @@ namespace GeometryTD.CustomComponent float randomWeight = (float)(random.NextDouble() * totalWeight); float cumulativeWeight = 0f; - foreach (var pair in _rarityWeightBuffer) + for (int rarityIndex = 0; rarityIndex < _rarityWeightBuffer.Length; rarityIndex++) { - cumulativeWeight += Mathf.Max(0f, pair.Value); + cumulativeWeight += Mathf.Max(0f, _rarityWeightBuffer[rarityIndex]); if (randomWeight <= cumulativeWeight) { - return pair.Key; + return OrderedRarities[rarityIndex]; } } - foreach (var pair in _rarityWeightBuffer) + for (int rarityIndex = 0; rarityIndex < _rarityWeightBuffer.Length; rarityIndex++) { - return pair.Key; + if (_rarityWeightBuffer[rarityIndex] > 0f) + { + return OrderedRarities[rarityIndex]; + } } return RarityType.None; @@ -201,9 +205,9 @@ namespace GeometryTD.CustomComponent private static bool IsEligibleAtPhase(DROutGameDropPool row, int displayPhaseIndex) { - for (int rarityIndex = (int)RarityType.White; rarityIndex <= (int)RarityType.Red; rarityIndex++) + for (int rarityIndex = 0; rarityIndex < OrderedRarities.Length; rarityIndex++) { - RarityType rarity = (RarityType)rarityIndex; + RarityType rarity = OrderedRarities[rarityIndex]; if (IsEligibleAtPhase(row, rarity, displayPhaseIndex)) { return true; diff --git a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs index 4b72a8a..3a29985 100644 --- a/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs +++ b/Assets/GameMain/Scripts/CustomComponent/InventoryGeneration/InventoryGenerationComponent.cs @@ -12,11 +12,6 @@ namespace GeometryTD.CustomComponent { public sealed class InventoryGenerationComponent : GameFrameworkComponent { - private int _runSeed; - private int _nodeSequenceIndex = -1; - private int _nextDropOrdinal; - private int _nextRewardOrdinal; - private readonly List _shopPriceRows = new(); private IDataTable _shopPriceTable; private IDataTable _dropPoolTable; @@ -28,21 +23,17 @@ namespace GeometryTD.CustomComponent private RewardCandidateBuilder _rewardCandidateBuilder; private OutGameDropItemBuilder _outGameDropItemBuilder; - public void ConfigureRunContext(int runSeed, int sequenceIndex) - { - _runSeed = runSeed; - _nodeSequenceIndex = sequenceIndex; - _nextDropOrdinal = 0; - _nextRewardOrdinal = 0; - } - public List BuildShopGoods(int goodsCount, int runSeed = 0, int sequenceIndex = -1) { EnsureShopBuilder(); return _shopGoodsBuilder.BuildGoods(goodsCount, runSeed, sequenceIndex); } - public EnemyDropResult ResolveEnemyDrop(in EnemyDropContext context) + public EnemyDropResult ResolveEnemyDrop( + in EnemyDropContext context, + int runSeed, + int sequenceIndex, + ref int nextDropOrdinal) { DREnemy enemy = context.Enemy; if (enemy == null) @@ -56,9 +47,10 @@ namespace GeometryTD.CustomComponent ? Mathf.Clamp01(enemy.DropPercent * 0.01f) : Mathf.Clamp01(enemy.DropPercent); - int dropOrdinal = AllocateDropOrdinal(); + int dropOrdinal = nextDropOrdinal; + nextDropOrdinal++; InventoryGenerationRandomContext randomContext = - new(_runSeed, _nodeSequenceIndex, InventoryTagSourceType.Drop, dropOrdinal); + new(runSeed, sequenceIndex, InventoryTagSourceType.Drop, dropOrdinal); Random random = randomContext.CreateRandom(); if (enemy.DropGold > 0 && dropRate > 0f && random.NextDouble() <= dropRate) @@ -84,15 +76,30 @@ namespace GeometryTD.CustomComponent public IReadOnlyList BuildRewardCandidates( int displayPhaseIndex, LevelThemeType themeType, - int candidateCount) + int candidateCount, + int runSeed, + int sequenceIndex, + ref int nextRewardOrdinal) { RewardCandidateBuilder rewardCandidateBuilder = EnsureRewardCandidateBuilder(); - return rewardCandidateBuilder.BuildCandidates( + int rewardOrdinal = nextRewardOrdinal; + IReadOnlyList candidates = rewardCandidateBuilder.BuildCandidates( displayPhaseIndex, themeType, candidateCount, CreateNextRewardRandomContext, BuildRewardCandidateItem); + nextRewardOrdinal = rewardOrdinal; + return candidates; + + InventoryGenerationRandomContext CreateNextRewardRandomContext() + { + return new InventoryGenerationRandomContext( + runSeed, + sequenceIndex, + InventoryTagSourceType.Reward, + rewardOrdinal++); + } } private void EnsureShopTables() @@ -205,25 +212,6 @@ namespace GeometryTD.CustomComponent return EnsureOutGameDropItemBuilder().TryBuildItem(selectedRow, selectedRarity, randomContext, out droppedItem); } - private int AllocateDropOrdinal() - { - return _nextDropOrdinal++; - } - - private int AllocateRewardOrdinal() - { - return _nextRewardOrdinal++; - } - - private InventoryGenerationRandomContext CreateNextRewardRandomContext() - { - return new InventoryGenerationRandomContext( - _runSeed, - _nodeSequenceIndex, - InventoryTagSourceType.Reward, - AllocateRewardOrdinal()); - } - private TowerCompItemData BuildRewardCandidateItem( DROutGameDropPool row, RarityType rarity, diff --git a/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs b/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs index af15f43..950da46 100644 --- a/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs +++ b/Assets/GameMain/Scripts/UI/General/Controller/RewardSelectFormController.cs @@ -110,7 +110,7 @@ namespace GeometryTD.UI return; } - RewardSelectFormRawData nextRawData = _useCase.TryRefresh(); + RewardSelectFormRawData nextRawData = _useCase.TryRotateSelection(); if (nextRawData == null) { CloseUI(); @@ -159,4 +159,4 @@ namespace GeometryTD.UI return false; } } -} \ No newline at end of file +} diff --git a/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs b/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs index b08d3d7..b64cc43 100644 --- a/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs +++ b/Assets/GameMain/Scripts/UI/General/UseCase/RewardSelectFormUseCase.cs @@ -15,17 +15,20 @@ namespace GeometryTD.UI private int _displayCount = 3; private int _refreshCost; - private bool _allowRefreshOnce = true; + private bool _allowRotateOnce = true; private bool _allowGiveUp = true; - private bool _hasRefreshed; + private bool _hasRotated; private int _selectionOffset; private string _tipText = "Select one reward"; + // RewardSelectForm keeps a fixed reward pool for the current node. + // The "refresh" action only rotates which slice of that pool is shown so save/load + // can reopen the same node with the exact same reward contents. public void ConfigureRewardPool( IReadOnlyList rewardPool, int displayCount = 3, int refreshCost = 0, - bool allowRefreshOnce = true, + bool allowRotateOnce = true, bool allowGiveUp = true, string tipText = null) { @@ -45,10 +48,10 @@ namespace GeometryTD.UI _displayCount = Mathf.Max(1, displayCount); _refreshCost = Mathf.Max(0, refreshCost); - _allowRefreshOnce = allowRefreshOnce; + _allowRotateOnce = allowRotateOnce; _allowGiveUp = allowGiveUp; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; - _hasRefreshed = false; + _hasRotated = false; _selectionOffset = 0; _currentModel = null; } @@ -57,7 +60,7 @@ namespace GeometryTD.UI IReadOnlyList rewardCandidates, int displayCount = 3, int refreshCost = 0, - bool allowRefreshOnce = true, + bool allowRotateOnce = true, bool allowGiveUp = true, string tipText = null) { @@ -77,10 +80,10 @@ namespace GeometryTD.UI _displayCount = Mathf.Max(1, displayCount); _refreshCost = Mathf.Max(0, refreshCost); - _allowRefreshOnce = allowRefreshOnce; + _allowRotateOnce = allowRotateOnce; _allowGiveUp = allowGiveUp; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; - _hasRefreshed = false; + _hasRotated = false; _selectionOffset = 0; _currentModel = null; } @@ -93,30 +96,30 @@ namespace GeometryTD.UI public RewardSelectFormRawData CreateInitialModel() { - _hasRefreshed = false; + _hasRotated = false; _currentModel = BuildModel(); return _currentModel; } - public RewardSelectFormRawData TryRefresh() + public RewardSelectFormRawData TryRotateSelection() { if (_currentModel == null) { return null; } - if (!CanRefreshInternal()) + if (!CanRotateSelectionInternal()) { return _currentModel; } - if (!TryConsumeRefreshCost()) + if (!TryConsumeRotateCost()) { _currentModel.CanRefresh = false; return _currentModel; } - _hasRefreshed = true; + _hasRotated = true; if (_rewardPool.Count > 0) { _selectionOffset = (_selectionOffset + _displayCount) % _rewardPool.Count; @@ -166,7 +169,7 @@ namespace GeometryTD.UI TipText = _tipText, RewardItems = selectedRewards, RefreshCost = _refreshCost, - CanRefresh = selectedRewards.Length > 0 && CanRefreshInternal(), + CanRefresh = selectedRewards.Length > 0 && CanRotateSelectionInternal(), CanGiveUp = _allowGiveUp }; } @@ -190,17 +193,17 @@ namespace GeometryTD.UI return results; } - private bool CanRefreshInternal() + private bool CanRotateSelectionInternal() { - if (!_allowRefreshOnce || _hasRefreshed) + if (!_allowRotateOnce || _hasRotated) { return false; } - return CanPayRefreshCost(); + return CanPayRotateCost(); } - private bool CanPayRefreshCost() + private bool CanPayRotateCost() { if (_refreshCost <= 0) { @@ -215,7 +218,7 @@ namespace GeometryTD.UI return GameEntry.PlayerInventory.Gold >= _refreshCost; } - private bool TryConsumeRefreshCost() + private bool TryConsumeRotateCost() { if (_refreshCost <= 0) { diff --git a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs index 0786d1f..3bafdbf 100644 --- a/Assets/Tests/EditMode/CombatSettlementServiceTests.cs +++ b/Assets/Tests/EditMode/CombatSettlementServiceTests.cs @@ -183,11 +183,15 @@ namespace GeometryTD.Tests.EditMode CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); CombatSettlementContext settlementContext = new CombatSettlementContext(); settlementContext.Flags.ShouldOpenRewardSelection = true; + int nextRewardOrdinal = 0; bool prepared = new CombatSettlementService().TryPrepareRewardSelection( settlementContext, displayPhaseIndex: 1, themeType: LevelThemeType.Plain, + runSeed: 1001, + sequenceIndex: 3, + ref nextRewardOrdinal, rewardSelectFormUseCase: null, onRewardSelected: _ => { }, onGiveUp: null); diff --git a/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs index 3e0c5c2..7b16066 100644 --- a/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs +++ b/Assets/Tests/EditMode/InventoryGenerationStabilityTests.cs @@ -110,7 +110,43 @@ namespace GeometryTD.Tests.EditMode } [Test] - public void RewardSelectFormUseCase_Uses_Stable_Order_And_Deterministic_Refresh_Rotation() + public void DropPoolRoller_Is_Reproducible_When_DropPool_Row_Order_Changes() + { + DROutGameDropPool whiteRow = CreateDropPoolRow(1, "MuzzleComp", 1, "[100,0,0,0,0]"); + DROutGameDropPool greenRow = CreateDropPoolRow(2, "BearingComp", 1, "[0,100,0,0,0]"); + DROutGameDropPool blueRow = CreateDropPoolRow(3, "BaseComp", 1, "[0,0,100,0,0]"); + DROutGameDropPool purpleRow = CreateDropPoolRow(4, "MuzzleComp", 2, "[0,0,0,100,0]"); + DROutGameDropPool redRow = CreateDropPoolRow(5, "BearingComp", 2, "[0,0,0,0,100]"); + + DropPoolRoller forwardOrderRoller = new DropPoolRoller( + new InsertionOrderDataTable(whiteRow, greenRow, blueRow, purpleRow, redRow)); + DropPoolRoller reverseOrderRoller = new DropPoolRoller( + new InsertionOrderDataTable(redRow, purpleRow, blueRow, greenRow, whiteRow)); + + for (int seed = 1; seed <= 64; seed++) + { + bool forwardRolled = forwardOrderRoller.TryRollRow( + 8, + LevelThemeType.Plain, + new System.Random(seed), + out DROutGameDropPool forwardRow, + out RarityType forwardRarity); + bool reverseRolled = reverseOrderRoller.TryRollRow( + 8, + LevelThemeType.Plain, + new System.Random(seed), + out DROutGameDropPool reverseRow, + out RarityType reverseRarity); + + Assert.That(forwardRolled, Is.True, $"seed={seed}"); + Assert.That(reverseRolled, Is.True, $"seed={seed}"); + Assert.That(reverseRarity, Is.EqualTo(forwardRarity), $"seed={seed}"); + Assert.That(reverseRow.Id, Is.EqualTo(forwardRow.Id), $"seed={seed}"); + } + } + + [Test] + public void RewardSelectFormUseCase_Uses_Stable_Order_And_Deterministic_Selection_Rotation() { RewardSelectFormUseCase useCase = new RewardSelectFormUseCase(); useCase.ConfigureRewardPool( @@ -123,11 +159,11 @@ namespace GeometryTD.Tests.EditMode }, displayCount: 3, refreshCost: 0, - allowRefreshOnce: true, + allowRotateOnce: true, allowGiveUp: false); RewardSelectFormRawData initialModel = useCase.CreateInitialModel(); - RewardSelectFormRawData refreshedModel = useCase.TryRefresh(); + RewardSelectFormRawData refreshedModel = useCase.TryRotateSelection(); Assert.That(initialModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "一号", "二号", "三号" })); Assert.That(refreshedModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "四号", "一号", "二号" })); @@ -141,15 +177,25 @@ namespace GeometryTD.Tests.EditMode private string BuildEnemyDropSignature(int runSeed, int sequenceIndex) { - _component.ConfigureRunContext(runSeed, sequenceIndex); - EnemyDropResult result = _component.ResolveEnemyDrop(new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain)); + int nextDropOrdinal = 0; + EnemyDropResult result = _component.ResolveEnemyDrop( + new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain), + runSeed, + sequenceIndex, + ref nextDropOrdinal); return BuildDropSignaturePart(result); } private string BuildRewardCandidateSignature(int runSeed, int sequenceIndex) { - _component.ConfigureRunContext(runSeed, sequenceIndex); - IReadOnlyList candidates = _component.BuildRewardCandidates(8, LevelThemeType.Plain, 3); + int nextRewardOrdinal = 0; + IReadOnlyList candidates = _component.BuildRewardCandidates( + 8, + LevelThemeType.Plain, + 3, + runSeed, + sequenceIndex, + ref nextRewardOrdinal); return string.Join("|", candidates.Select(BuildItemSignaturePart)); } @@ -433,5 +479,185 @@ namespace GeometryTD.Tests.EditMode return ids; } } + + private sealed class InsertionOrderDataTable : IDataTable where TRow : class, IDataRow + { + private readonly List _rows = new(); + private readonly Dictionary _rowsById = new(); + + public InsertionOrderDataTable(params TRow[] rows) + { + if (rows == null) + { + return; + } + + for (int i = 0; i < rows.Length; i++) + { + TRow row = rows[i]; + if (row == null) + { + continue; + } + + _rows.Add(row); + _rowsById[row.Id] = row; + } + } + + public string Name => typeof(TRow).Name; + public string FullName => typeof(TRow).FullName; + public Type Type => typeof(TRow); + public int Count => _rows.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; + } + + for (int i = 0; i < _rows.Count; i++) + { + TRow row = _rows[i]; + 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; + } + + for (int i = 0; i < _rows.Count; i++) + { + TRow row = _rows[i]; + 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(_rows); + 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; + } + + results.AddRange(_rows); + } + + 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) + { + if (!_rowsById.TryGetValue(id, out TRow row)) + { + return false; + } + + _rowsById.Remove(id); + return _rows.Remove(row); + } + + public void RemoveAllDataRows() + { + _rows.Clear(); + _rowsById.Clear(); + } + + public IEnumerator GetEnumerator() + { + for (int i = 0; i < _rows.Count; i++) + { + yield return _rows[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + private int[] GetOrderedIds() + { + int[] ids = _rowsById.Keys.ToArray(); + Array.Sort(ids); + return ids; + } + } } }