修复已知问题

This commit is contained in:
SepComet 2026-03-13 14:55:17 +08:00
parent aa44170d56
commit 777c58b812
11 changed files with 346 additions and 97 deletions

View File

@ -100,7 +100,8 @@ namespace GeometryTD.CustomComponent
_runtime.NodeId = nodeId; _runtime.NodeId = nodeId;
_runtime.NodeType = nodeType; _runtime.NodeType = nodeType;
_runtime.SequenceIndex = sequenceIndex; _runtime.SequenceIndex = sequenceIndex;
GameEntry.InventoryGeneration.ConfigureRunContext(runSeed, sequenceIndex); _runtime.NextDropOrdinal = 0;
_runtime.NextRewardOrdinal = 0;
_runtime.CombatRunResourceStore.InitializeForCombat(level); _runtime.CombatRunResourceStore.InitializeForCombat(level);
for (int i = 0; i < phases.Count; i++) for (int i = 0; i < phases.Count; i++)
{ {
@ -201,7 +202,13 @@ namespace GeometryTD.CustomComponent
enemy, enemy,
_runtime.PhaseLoopRuntime.DisplayPhaseIndex, _runtime.PhaseLoopRuntime.DisplayPhaseIndex,
_coordinator.ResolveCurrentThemeType()); _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.AddEnemyDefeatedReward(result.Coin, result.Gold);
_runtime.CombatRunResourceStore.AddEnemyDefeatedLoot(result.LootItem); _runtime.CombatRunResourceStore.AddEnemyDefeatedLoot(result.LootItem);
} }

View File

@ -60,6 +60,8 @@ namespace GeometryTD.CustomComponent
_runtime.NodeId = 0; _runtime.NodeId = 0;
_runtime.NodeType = RunNodeType.None; _runtime.NodeType = RunNodeType.None;
_runtime.SequenceIndex = -1; _runtime.SequenceIndex = -1;
_runtime.NextDropOrdinal = 0;
_runtime.NextRewardOrdinal = 0;
} }
public void CleanupAllCombatEntities() public void CleanupAllCombatEntities()

View File

@ -34,5 +34,7 @@ namespace GeometryTD.CustomComponent
public int NodeId { get; set; } public int NodeId { get; set; }
public RunNodeType NodeType { get; set; } public RunNodeType NodeType { get; set; }
public int SequenceIndex { get; set; } public int SequenceIndex { get; set; }
public int NextDropOrdinal { get; set; }
public int NextRewardOrdinal { get; set; }
} }
} }

View File

@ -36,6 +36,9 @@ namespace GeometryTD.CustomComponent
CombatSettlementContext settlementContext, CombatSettlementContext settlementContext,
int displayPhaseIndex, int displayPhaseIndex,
LevelThemeType themeType, LevelThemeType themeType,
int runSeed,
int sequenceIndex,
ref int nextRewardOrdinal,
RewardSelectFormUseCase rewardSelectFormUseCase, RewardSelectFormUseCase rewardSelectFormUseCase,
Action<RewardSelectItemRawData> onRewardSelected, Action<RewardSelectItemRawData> onRewardSelected,
Action onGiveUp) Action onGiveUp)
@ -48,7 +51,10 @@ namespace GeometryTD.CustomComponent
IReadOnlyList<TowerCompItemData> candidateItems = GameEntry.InventoryGeneration.BuildRewardCandidates( IReadOnlyList<TowerCompItemData> candidateItems = GameEntry.InventoryGeneration.BuildRewardCandidates(
displayPhaseIndex, displayPhaseIndex,
themeType, themeType,
RewardSelectDisplayCount); RewardSelectDisplayCount,
runSeed,
sequenceIndex,
ref nextRewardOrdinal);
if (candidateItems == null || candidateItems.Count <= 0) if (candidateItems == null || candidateItems.Count <= 0)
{ {
settlementContext.Flags.ShouldOpenRewardSelection = false; settlementContext.Flags.ShouldOpenRewardSelection = false;
@ -60,7 +66,7 @@ namespace GeometryTD.CustomComponent
candidateItems, candidateItems,
displayCount: RewardSelectDisplayCount, displayCount: RewardSelectDisplayCount,
refreshCost: 0, refreshCost: 0,
allowRefreshOnce: false, allowRotateOnce: false,
allowGiveUp: false, allowGiveUp: false,
tipText: "基地满血奖励:请选择 1 个组件"); tipText: "基地满血奖励:请选择 1 个组件");

View File

@ -16,16 +16,23 @@ namespace GeometryTD.CustomComponent
} }
Coordinator.EnsureRewardSelectFormUseCaseBound(); Coordinator.EnsureRewardSelectFormUseCaseBound();
int nextRewardOrdinal = Runtime.NextRewardOrdinal;
if (!Runtime.CombatSettlementService.TryPrepareRewardSelection( if (!Runtime.CombatSettlementService.TryPrepareRewardSelection(
Runtime.SettlementContext, Runtime.SettlementContext,
Runtime.PhaseLoopRuntime.DisplayPhaseIndex, Runtime.PhaseLoopRuntime.DisplayPhaseIndex,
Coordinator.ResolveCurrentThemeType(), Coordinator.ResolveCurrentThemeType(),
Runtime.RunSeed,
Runtime.SequenceIndex,
ref nextRewardOrdinal,
Runtime.RewardSelectFormUseCase, Runtime.RewardSelectFormUseCase,
Coordinator.OnFullBaseHpRewardSelected, Coordinator.OnFullBaseHpRewardSelected,
Coordinator.OnFullBaseHpRewardGiveUp)) Coordinator.OnFullBaseHpRewardGiveUp))
{ {
Coordinator.ChangeState(new CombatFinishFormState(Runtime, Coordinator)); Coordinator.ChangeState(new CombatFinishFormState(Runtime, Coordinator));
return;
} }
Runtime.NextRewardOrdinal = nextRewardOrdinal;
} }
public override void OnExit() public override void OnExit()

View File

@ -10,9 +10,17 @@ namespace GeometryTD.CustomComponent
public sealed class DropPoolRoller public sealed class DropPoolRoller
{ {
private const float RarityCurveScalePhase = 30f; private const float RarityCurveScalePhase = 30f;
private static readonly RarityType[] OrderedRarities =
{
RarityType.White,
RarityType.Green,
RarityType.Blue,
RarityType.Purple,
RarityType.Red
};
private readonly List<DROutGameDropPool> _eligibleRowBuffer = new(); private readonly List<DROutGameDropPool> _eligibleRowBuffer = new();
private readonly Dictionary<RarityType, float> _rarityWeightBuffer = new(); private readonly float[] _rarityWeightBuffer = new float[OrderedRarities.Length];
private readonly IDataTable<DROutGameDropPool> _dropPoolTable; private readonly IDataTable<DROutGameDropPool> _dropPoolTable;
public DropPoolRoller(IDataTable<DROutGameDropPool> dropPoolTable) public DropPoolRoller(IDataTable<DROutGameDropPool> dropPoolTable)
@ -50,9 +58,8 @@ namespace GeometryTD.CustomComponent
int totalWeight = 0; int totalWeight = 0;
DROutGameDropPool fallbackRow = null; 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)) if (!IsEligibleAtPhase(row, selectedRarity, displayPhaseIndex))
{ {
continue; continue;
@ -107,9 +114,8 @@ namespace GeometryTD.CustomComponent
{ {
_eligibleRowBuffer.Clear(); _eligibleRowBuffer.Clear();
for (int i = 0; i < allRows.Length; i++) foreach (var row in allRows)
{ {
DROutGameDropPool row = allRows[i];
if (row == null) if (row == null)
{ {
continue; continue;
@ -131,15 +137,18 @@ namespace GeometryTD.CustomComponent
private RarityType RollRarity(int displayPhaseIndex, Random random) 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); 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 = 0; rarityIndex < OrderedRarities.Length; rarityIndex++)
for (int rarityIndex = (int)RarityType.White; rarityIndex <= (int)RarityType.Red; rarityIndex++)
{ {
RarityType rarity = (RarityType)rarityIndex; RarityType rarity = OrderedRarities[rarityIndex];
if (!IsEligibleAtPhase(row, rarity, displayPhaseIndex)) if (!IsEligibleAtPhase(row, rarity, displayPhaseIndex))
{ {
continue; continue;
@ -157,22 +166,14 @@ namespace GeometryTD.CustomComponent
continue; continue;
} }
float rarityWeight = rowWeight * curveWeight; _rarityWeightBuffer[rarityIndex] += rowWeight * curveWeight;
if (_rarityWeightBuffer.TryGetValue(rarity, out float existingWeight))
{
_rarityWeightBuffer[rarity] = existingWeight + rarityWeight;
}
else
{
_rarityWeightBuffer[rarity] = rarityWeight;
}
} }
} }
float totalWeight = 0f; 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) if (totalWeight <= 0f)
@ -182,18 +183,21 @@ namespace GeometryTD.CustomComponent
float randomWeight = (float)(random.NextDouble() * totalWeight); float randomWeight = (float)(random.NextDouble() * totalWeight);
float cumulativeWeight = 0f; 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) 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; return RarityType.None;
@ -201,9 +205,9 @@ namespace GeometryTD.CustomComponent
private static bool IsEligibleAtPhase(DROutGameDropPool row, int displayPhaseIndex) 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)) if (IsEligibleAtPhase(row, rarity, displayPhaseIndex))
{ {
return true; return true;

View File

@ -12,11 +12,6 @@ namespace GeometryTD.CustomComponent
{ {
public sealed class InventoryGenerationComponent : GameFrameworkComponent public sealed class InventoryGenerationComponent : GameFrameworkComponent
{ {
private int _runSeed;
private int _nodeSequenceIndex = -1;
private int _nextDropOrdinal;
private int _nextRewardOrdinal;
private readonly List<DRShopPrice> _shopPriceRows = new(); private readonly List<DRShopPrice> _shopPriceRows = new();
private IDataTable<DRShopPrice> _shopPriceTable; private IDataTable<DRShopPrice> _shopPriceTable;
private IDataTable<DROutGameDropPool> _dropPoolTable; private IDataTable<DROutGameDropPool> _dropPoolTable;
@ -28,21 +23,17 @@ namespace GeometryTD.CustomComponent
private RewardCandidateBuilder _rewardCandidateBuilder; private RewardCandidateBuilder _rewardCandidateBuilder;
private OutGameDropItemBuilder _outGameDropItemBuilder; private OutGameDropItemBuilder _outGameDropItemBuilder;
public void ConfigureRunContext(int runSeed, int sequenceIndex)
{
_runSeed = runSeed;
_nodeSequenceIndex = sequenceIndex;
_nextDropOrdinal = 0;
_nextRewardOrdinal = 0;
}
public List<GoodsItemRawData> BuildShopGoods(int goodsCount, int runSeed = 0, int sequenceIndex = -1) public List<GoodsItemRawData> BuildShopGoods(int goodsCount, int runSeed = 0, int sequenceIndex = -1)
{ {
EnsureShopBuilder(); EnsureShopBuilder();
return _shopGoodsBuilder.BuildGoods(goodsCount, runSeed, sequenceIndex); 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; DREnemy enemy = context.Enemy;
if (enemy == null) if (enemy == null)
@ -56,9 +47,10 @@ namespace GeometryTD.CustomComponent
? Mathf.Clamp01(enemy.DropPercent * 0.01f) ? Mathf.Clamp01(enemy.DropPercent * 0.01f)
: Mathf.Clamp01(enemy.DropPercent); : Mathf.Clamp01(enemy.DropPercent);
int dropOrdinal = AllocateDropOrdinal(); int dropOrdinal = nextDropOrdinal;
nextDropOrdinal++;
InventoryGenerationRandomContext randomContext = InventoryGenerationRandomContext randomContext =
new(_runSeed, _nodeSequenceIndex, InventoryTagSourceType.Drop, dropOrdinal); new(runSeed, sequenceIndex, InventoryTagSourceType.Drop, dropOrdinal);
Random random = randomContext.CreateRandom(); Random random = randomContext.CreateRandom();
if (enemy.DropGold > 0 && dropRate > 0f && random.NextDouble() <= dropRate) if (enemy.DropGold > 0 && dropRate > 0f && random.NextDouble() <= dropRate)
@ -84,15 +76,30 @@ namespace GeometryTD.CustomComponent
public IReadOnlyList<TowerCompItemData> BuildRewardCandidates( public IReadOnlyList<TowerCompItemData> BuildRewardCandidates(
int displayPhaseIndex, int displayPhaseIndex,
LevelThemeType themeType, LevelThemeType themeType,
int candidateCount) int candidateCount,
int runSeed,
int sequenceIndex,
ref int nextRewardOrdinal)
{ {
RewardCandidateBuilder rewardCandidateBuilder = EnsureRewardCandidateBuilder(); RewardCandidateBuilder rewardCandidateBuilder = EnsureRewardCandidateBuilder();
return rewardCandidateBuilder.BuildCandidates( int rewardOrdinal = nextRewardOrdinal;
IReadOnlyList<TowerCompItemData> candidates = rewardCandidateBuilder.BuildCandidates(
displayPhaseIndex, displayPhaseIndex,
themeType, themeType,
candidateCount, candidateCount,
CreateNextRewardRandomContext, CreateNextRewardRandomContext,
BuildRewardCandidateItem); BuildRewardCandidateItem);
nextRewardOrdinal = rewardOrdinal;
return candidates;
InventoryGenerationRandomContext CreateNextRewardRandomContext()
{
return new InventoryGenerationRandomContext(
runSeed,
sequenceIndex,
InventoryTagSourceType.Reward,
rewardOrdinal++);
}
} }
private void EnsureShopTables() private void EnsureShopTables()
@ -205,25 +212,6 @@ namespace GeometryTD.CustomComponent
return EnsureOutGameDropItemBuilder().TryBuildItem(selectedRow, selectedRarity, randomContext, out droppedItem); 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( private TowerCompItemData BuildRewardCandidateItem(
DROutGameDropPool row, DROutGameDropPool row,
RarityType rarity, RarityType rarity,

View File

@ -110,7 +110,7 @@ namespace GeometryTD.UI
return; return;
} }
RewardSelectFormRawData nextRawData = _useCase.TryRefresh(); RewardSelectFormRawData nextRawData = _useCase.TryRotateSelection();
if (nextRawData == null) if (nextRawData == null)
{ {
CloseUI(); CloseUI();
@ -159,4 +159,4 @@ namespace GeometryTD.UI
return false; return false;
} }
} }
} }

View File

@ -15,17 +15,20 @@ namespace GeometryTD.UI
private int _displayCount = 3; private int _displayCount = 3;
private int _refreshCost; private int _refreshCost;
private bool _allowRefreshOnce = true; private bool _allowRotateOnce = true;
private bool _allowGiveUp = true; private bool _allowGiveUp = true;
private bool _hasRefreshed; private bool _hasRotated;
private int _selectionOffset; private int _selectionOffset;
private string _tipText = "Select one reward"; 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( public void ConfigureRewardPool(
IReadOnlyList<RewardSelectItemRawData> rewardPool, IReadOnlyList<RewardSelectItemRawData> rewardPool,
int displayCount = 3, int displayCount = 3,
int refreshCost = 0, int refreshCost = 0,
bool allowRefreshOnce = true, bool allowRotateOnce = true,
bool allowGiveUp = true, bool allowGiveUp = true,
string tipText = null) string tipText = null)
{ {
@ -45,10 +48,10 @@ namespace GeometryTD.UI
_displayCount = Mathf.Max(1, displayCount); _displayCount = Mathf.Max(1, displayCount);
_refreshCost = Mathf.Max(0, refreshCost); _refreshCost = Mathf.Max(0, refreshCost);
_allowRefreshOnce = allowRefreshOnce; _allowRotateOnce = allowRotateOnce;
_allowGiveUp = allowGiveUp; _allowGiveUp = allowGiveUp;
_tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText;
_hasRefreshed = false; _hasRotated = false;
_selectionOffset = 0; _selectionOffset = 0;
_currentModel = null; _currentModel = null;
} }
@ -57,7 +60,7 @@ namespace GeometryTD.UI
IReadOnlyList<TowerCompItemData> rewardCandidates, IReadOnlyList<TowerCompItemData> rewardCandidates,
int displayCount = 3, int displayCount = 3,
int refreshCost = 0, int refreshCost = 0,
bool allowRefreshOnce = true, bool allowRotateOnce = true,
bool allowGiveUp = true, bool allowGiveUp = true,
string tipText = null) string tipText = null)
{ {
@ -77,10 +80,10 @@ namespace GeometryTD.UI
_displayCount = Mathf.Max(1, displayCount); _displayCount = Mathf.Max(1, displayCount);
_refreshCost = Mathf.Max(0, refreshCost); _refreshCost = Mathf.Max(0, refreshCost);
_allowRefreshOnce = allowRefreshOnce; _allowRotateOnce = allowRotateOnce;
_allowGiveUp = allowGiveUp; _allowGiveUp = allowGiveUp;
_tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText; _tipText = string.IsNullOrWhiteSpace(tipText) ? "Select one reward" : tipText;
_hasRefreshed = false; _hasRotated = false;
_selectionOffset = 0; _selectionOffset = 0;
_currentModel = null; _currentModel = null;
} }
@ -93,30 +96,30 @@ namespace GeometryTD.UI
public RewardSelectFormRawData CreateInitialModel() public RewardSelectFormRawData CreateInitialModel()
{ {
_hasRefreshed = false; _hasRotated = false;
_currentModel = BuildModel(); _currentModel = BuildModel();
return _currentModel; return _currentModel;
} }
public RewardSelectFormRawData TryRefresh() public RewardSelectFormRawData TryRotateSelection()
{ {
if (_currentModel == null) if (_currentModel == null)
{ {
return null; return null;
} }
if (!CanRefreshInternal()) if (!CanRotateSelectionInternal())
{ {
return _currentModel; return _currentModel;
} }
if (!TryConsumeRefreshCost()) if (!TryConsumeRotateCost())
{ {
_currentModel.CanRefresh = false; _currentModel.CanRefresh = false;
return _currentModel; return _currentModel;
} }
_hasRefreshed = true; _hasRotated = true;
if (_rewardPool.Count > 0) if (_rewardPool.Count > 0)
{ {
_selectionOffset = (_selectionOffset + _displayCount) % _rewardPool.Count; _selectionOffset = (_selectionOffset + _displayCount) % _rewardPool.Count;
@ -166,7 +169,7 @@ namespace GeometryTD.UI
TipText = _tipText, TipText = _tipText,
RewardItems = selectedRewards, RewardItems = selectedRewards,
RefreshCost = _refreshCost, RefreshCost = _refreshCost,
CanRefresh = selectedRewards.Length > 0 && CanRefreshInternal(), CanRefresh = selectedRewards.Length > 0 && CanRotateSelectionInternal(),
CanGiveUp = _allowGiveUp CanGiveUp = _allowGiveUp
}; };
} }
@ -190,17 +193,17 @@ namespace GeometryTD.UI
return results; return results;
} }
private bool CanRefreshInternal() private bool CanRotateSelectionInternal()
{ {
if (!_allowRefreshOnce || _hasRefreshed) if (!_allowRotateOnce || _hasRotated)
{ {
return false; return false;
} }
return CanPayRefreshCost(); return CanPayRotateCost();
} }
private bool CanPayRefreshCost() private bool CanPayRotateCost()
{ {
if (_refreshCost <= 0) if (_refreshCost <= 0)
{ {
@ -215,7 +218,7 @@ namespace GeometryTD.UI
return GameEntry.PlayerInventory.Gold >= _refreshCost; return GameEntry.PlayerInventory.Gold >= _refreshCost;
} }
private bool TryConsumeRefreshCost() private bool TryConsumeRotateCost()
{ {
if (_refreshCost <= 0) if (_refreshCost <= 0)
{ {

View File

@ -183,11 +183,15 @@ namespace GeometryTD.Tests.EditMode
CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f)); CreateBoundPlayerInventory(CreateInventory(100f, 100f, 100f));
CombatSettlementContext settlementContext = new CombatSettlementContext(); CombatSettlementContext settlementContext = new CombatSettlementContext();
settlementContext.Flags.ShouldOpenRewardSelection = true; settlementContext.Flags.ShouldOpenRewardSelection = true;
int nextRewardOrdinal = 0;
bool prepared = new CombatSettlementService().TryPrepareRewardSelection( bool prepared = new CombatSettlementService().TryPrepareRewardSelection(
settlementContext, settlementContext,
displayPhaseIndex: 1, displayPhaseIndex: 1,
themeType: LevelThemeType.Plain, themeType: LevelThemeType.Plain,
runSeed: 1001,
sequenceIndex: 3,
ref nextRewardOrdinal,
rewardSelectFormUseCase: null, rewardSelectFormUseCase: null,
onRewardSelected: _ => { }, onRewardSelected: _ => { },
onGiveUp: null); onGiveUp: null);

View File

@ -110,7 +110,43 @@ namespace GeometryTD.Tests.EditMode
} }
[Test] [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<DROutGameDropPool>(whiteRow, greenRow, blueRow, purpleRow, redRow));
DropPoolRoller reverseOrderRoller = new DropPoolRoller(
new InsertionOrderDataTable<DROutGameDropPool>(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(); RewardSelectFormUseCase useCase = new RewardSelectFormUseCase();
useCase.ConfigureRewardPool( useCase.ConfigureRewardPool(
@ -123,11 +159,11 @@ namespace GeometryTD.Tests.EditMode
}, },
displayCount: 3, displayCount: 3,
refreshCost: 0, refreshCost: 0,
allowRefreshOnce: true, allowRotateOnce: true,
allowGiveUp: false); allowGiveUp: false);
RewardSelectFormRawData initialModel = useCase.CreateInitialModel(); 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(initialModel.RewardItems.Select(item => item.Title).ToArray(), Is.EqualTo(new[] { "一号", "二号", "三号" }));
Assert.That(refreshedModel.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) private string BuildEnemyDropSignature(int runSeed, int sequenceIndex)
{ {
_component.ConfigureRunContext(runSeed, sequenceIndex); int nextDropOrdinal = 0;
EnemyDropResult result = _component.ResolveEnemyDrop(new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain)); EnemyDropResult result = _component.ResolveEnemyDrop(
new EnemyDropContext(CreateEnemyRow(), 8, LevelThemeType.Plain),
runSeed,
sequenceIndex,
ref nextDropOrdinal);
return BuildDropSignaturePart(result); return BuildDropSignaturePart(result);
} }
private string BuildRewardCandidateSignature(int runSeed, int sequenceIndex) private string BuildRewardCandidateSignature(int runSeed, int sequenceIndex)
{ {
_component.ConfigureRunContext(runSeed, sequenceIndex); int nextRewardOrdinal = 0;
IReadOnlyList<TowerCompItemData> candidates = _component.BuildRewardCandidates(8, LevelThemeType.Plain, 3); IReadOnlyList<TowerCompItemData> candidates = _component.BuildRewardCandidates(
8,
LevelThemeType.Plain,
3,
runSeed,
sequenceIndex,
ref nextRewardOrdinal);
return string.Join("|", candidates.Select(BuildItemSignaturePart)); return string.Join("|", candidates.Select(BuildItemSignaturePart));
} }
@ -433,5 +479,185 @@ namespace GeometryTD.Tests.EditMode
return ids; return ids;
} }
} }
private sealed class InsertionOrderDataTable<TRow> : IDataTable<TRow> where TRow : class, IDataRow
{
private readonly List<TRow> _rows = new();
private readonly Dictionary<int, TRow> _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<TRow> condition) => GetDataRow(condition) != null;
public TRow GetDataRow(int id) => _rowsById.TryGetValue(id, out TRow row) ? row : null;
public TRow GetDataRow(Predicate<TRow> 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<TRow> condition)
{
List<TRow> results = new();
GetDataRows(condition, results);
return results.ToArray();
}
public void GetDataRows(Predicate<TRow> condition, List<TRow> 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<TRow> comparison)
{
List<TRow> results = new();
GetDataRows(comparison, results);
return results.ToArray();
}
public void GetDataRows(Comparison<TRow> comparison, List<TRow> results)
{
results?.Clear();
if (results == null)
{
return;
}
results.AddRange(_rows);
if (comparison != null)
{
results.Sort(comparison);
}
}
public TRow[] GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison)
{
List<TRow> results = new();
GetDataRows(condition, comparison, results);
return results.ToArray();
}
public void GetDataRows(Predicate<TRow> condition, Comparison<TRow> comparison, List<TRow> results)
{
GetDataRows(condition, results);
if (results != null && comparison != null)
{
results.Sort(comparison);
}
}
public TRow[] GetAllDataRows()
{
List<TRow> results = new();
GetAllDataRows(results);
return results.ToArray();
}
public void GetAllDataRows(List<TRow> 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<TRow> 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;
}
}
} }
} }