From 4247782c018d76aafd9f779e8832f493d116b638 Mon Sep 17 00:00:00 2001 From: "JSD\\13999" <1399945104@qq.com> Date: Thu, 25 Jun 2026 10:45:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(lucky-dice):=20=E6=B7=BB=E5=8A=A0=20PoolFu?= =?UTF-8?q?nnel=20=E9=9A=8F=E6=9C=BA=E6=9C=8D=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Runtime/LuckyDice/PoolFunnel.meta | 8 + .../LuckyDice/PoolFunnel/PoolFunnelModels.cs | 352 ++++++++++++++++++ .../PoolFunnel/PoolFunnelModels.cs.meta | 11 + .../PoolFunnel/PoolFunnelRandomService.cs | 227 +++++++++++ .../PoolFunnelRandomService.cs.meta | 11 + .../PoolFunnel/PoolFunnelStateStore.cs | 41 ++ .../PoolFunnel/PoolFunnelStateStore.cs.meta | 11 + .../PoolFunnel/PoolFunnelStrategies.cs | 125 +++++++ .../PoolFunnel/PoolFunnelStrategies.cs.meta | 11 + .../LuckyDice/PoolFunnelRandomTests.cs | 249 +++++++++++++ .../LuckyDice/PoolFunnelRandomTests.cs.meta | 11 + 11 files changed, 1057 insertions(+) create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel.meta create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs.meta create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs.meta create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs.meta create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs create mode 100644 FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs.meta create mode 100644 FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs create mode 100644 FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs.meta diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel.meta b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel.meta new file mode 100644 index 0000000..399764d --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5c5be5cf8dd34b0b80b7fb0f681fbb6d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs new file mode 100644 index 0000000..50cae77 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FishDice.LuckyDice +{ + public enum PoolFunnelDecisionType + { + Stop, + NextLayer + } + + public sealed class PoolFunnelDecision + { + private PoolFunnelDecision(PoolFunnelDecisionType decisionType, string nextLayerId) + { + DecisionType = decisionType; + NextLayerId = nextLayerId; + } + + public PoolFunnelDecisionType DecisionType { get; } + + public string NextLayerId { get; } + + public static PoolFunnelDecision Stop() + { + return new PoolFunnelDecision(PoolFunnelDecisionType.Stop, null); + } + + public static PoolFunnelDecision NextLayer(string layerId) + { + if (string.IsNullOrWhiteSpace(layerId)) + { + throw new ArgumentException("Next layer id is required.", nameof(layerId)); + } + + return new PoolFunnelDecision(PoolFunnelDecisionType.NextLayer, layerId); + } + } + + public sealed class PoolFunnelRequest + { + public PoolFunnelRequest(string sessionId, string triggerSource) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + throw new ArgumentException("Session id is required.", nameof(sessionId)); + } + + SessionId = sessionId; + TriggerSource = triggerSource; + } + + public string SessionId { get; } + + public string TriggerSource { get; } + } + + public sealed class PoolFunnelContext + { + public PoolFunnelContext(RandomPoolDefinition pool, PoolFunnelRequest request) + { + Pool = pool ?? throw new ArgumentNullException(nameof(pool)); + Request = request ?? throw new ArgumentNullException(nameof(request)); + } + + public RandomPoolDefinition Pool { get; } + + public PoolFunnelRequest Request { get; } + } + + public sealed class RandomPoolDefinition + { + private readonly IReadOnlyDictionary _layersById; + + public RandomPoolDefinition( + string poolId, + IReadOnlyList entryLayerIds, + IReadOnlyList layers) + { + if (string.IsNullOrWhiteSpace(poolId)) + { + throw new ArgumentException("Pool id is required.", nameof(poolId)); + } + + if (entryLayerIds == null || entryLayerIds.Count == 0) + { + throw new ArgumentException("At least one entry layer is required.", nameof(entryLayerIds)); + } + + if (layers == null || layers.Count == 0) + { + throw new ArgumentException("At least one layer is required.", nameof(layers)); + } + + PoolId = poolId; + EntryLayerIds = entryLayerIds.ToArray(); + Layers = layers.ToArray(); + _layersById = Layers.ToDictionary(layer => layer.LayerId, layer => layer); + } + + public string PoolId { get; } + + public IReadOnlyList EntryLayerIds { get; } + + public IReadOnlyList Layers { get; } + + public bool TryGetLayer(string layerId, out RandomLayerDefinition layer) + { + return _layersById.TryGetValue(layerId, out layer); + } + + public RandomLayerDefinition GetLayer(string layerId) + { + RandomLayerDefinition layer; + if (!TryGetLayer(layerId, out layer)) + { + throw new InvalidOperationException("Missing random layer: " + layerId); + } + + return layer; + } + } + + public sealed class RandomLayerDefinition + { + public RandomLayerDefinition( + string layerId, + int priority, + string availabilityKey, + IReadOnlyList results) + { + if (string.IsNullOrWhiteSpace(layerId)) + { + throw new ArgumentException("Layer id is required.", nameof(layerId)); + } + + if (results == null || results.Count == 0) + { + throw new ArgumentException("At least one result is required.", nameof(results)); + } + + LayerId = layerId; + Priority = priority; + AvailabilityKey = availabilityKey; + Results = results.ToArray(); + } + + public string LayerId { get; } + + public int Priority { get; } + + public string AvailabilityKey { get; } + + public IReadOnlyList Results { get; } + } + + public sealed class RandomResultDefinition + { + public RandomResultDefinition( + string resultKey, + int priority, + int baseWeight, + int luckValueWeightFactor, + int luckValueDelta, + PoolFunnelDecision decision, + IReadOnlyList actions = null, + string settlementKey = null) + { + if (string.IsNullOrWhiteSpace(resultKey)) + { + throw new ArgumentException("Result key is required.", nameof(resultKey)); + } + + ResultKey = resultKey; + Priority = priority; + BaseWeight = baseWeight; + LuckValueWeightFactor = luckValueWeightFactor; + LuckValueDelta = luckValueDelta; + Decision = decision ?? PoolFunnelDecision.Stop(); + Actions = actions == null ? Array.Empty() : actions.ToArray(); + SettlementKey = settlementKey; + } + + public string ResultKey { get; } + + public int Priority { get; } + + public int BaseWeight { get; } + + public int LuckValueWeightFactor { get; } + + public int LuckValueDelta { get; } + + public PoolFunnelDecision Decision { get; } + + public IReadOnlyList Actions { get; } + + public string SettlementKey { get; } + } + + public sealed class RandomPoolState + { + public RandomPoolState(string poolId, string layerId, int luckValue, int rollCount) + { + if (string.IsNullOrWhiteSpace(poolId)) + { + throw new ArgumentException("Pool id is required.", nameof(poolId)); + } + + if (string.IsNullOrWhiteSpace(layerId)) + { + throw new ArgumentException("Layer id is required.", nameof(layerId)); + } + + PoolId = poolId; + LayerId = layerId; + LuckValue = Math.Max(0, luckValue); + RollCount = Math.Max(0, rollCount); + } + + public string PoolId { get; } + + public string LayerId { get; } + + public int LuckValue { get; } + + public int RollCount { get; } + + public RandomPoolState With(int luckValue, int rollCount) + { + return new RandomPoolState(PoolId, LayerId, luckValue, rollCount); + } + } + + public sealed class WeightedPoolFunnelResult + { + public WeightedPoolFunnelResult(string resultKey, int priority, int weight) + { + ResultKey = resultKey; + Priority = priority; + Weight = weight; + } + + public string ResultKey { get; } + + public int Priority { get; } + + public int Weight { get; } + } + + public sealed class PoolFunnelLayerResult + { + public PoolFunnelLayerResult( + string layerId, + RandomPoolState stateBefore, + RandomPoolState stateAfter, + IReadOnlyList weights, + int? randomValue, + RandomResultDefinition selectedResult, + IReadOnlyList actionResults, + PoolFunnelDecision decision, + string outcome) + { + LayerId = layerId; + StateBefore = stateBefore; + StateAfter = stateAfter; + Weights = weights == null ? Array.Empty() : weights.ToArray(); + RandomValue = randomValue; + SelectedResult = selectedResult; + ActionResults = actionResults == null ? Array.Empty() : actionResults.ToArray(); + Decision = decision ?? PoolFunnelDecision.Stop(); + Outcome = outcome; + } + + public string LayerId { get; } + + public RandomPoolState StateBefore { get; } + + public RandomPoolState StateAfter { get; } + + public IReadOnlyList Weights { get; } + + public int? RandomValue { get; } + + public RandomResultDefinition SelectedResult { get; } + + public IReadOnlyList ActionResults { get; } + + public PoolFunnelDecision Decision { get; } + + public string Outcome { get; } + } + + public sealed class PoolFunnelTrace + { + public PoolFunnelTrace( + string sessionId, + string poolId, + string selectedEntryLayerId, + IReadOnlyList unavailableEntryLayerIds, + IReadOnlyList layers) + { + SessionId = sessionId; + PoolId = poolId; + SelectedEntryLayerId = selectedEntryLayerId; + UnavailableEntryLayerIds = unavailableEntryLayerIds == null + ? Array.Empty() + : unavailableEntryLayerIds.ToArray(); + Layers = layers == null ? Array.Empty() : layers.ToArray(); + } + + public string SessionId { get; } + + public string PoolId { get; } + + public string SelectedEntryLayerId { get; } + + public IReadOnlyList UnavailableEntryLayerIds { get; } + + public IReadOnlyList Layers { get; } + } + + public sealed class PoolFunnelRunResult + { + public PoolFunnelRunResult( + string sessionId, + string poolId, + IReadOnlyList layers, + string outcome, + PoolFunnelTrace trace) + { + SessionId = sessionId; + PoolId = poolId; + Layers = layers == null ? Array.Empty() : layers.ToArray(); + Outcome = outcome; + Trace = trace; + } + + public string SessionId { get; } + + public string PoolId { get; } + + public IReadOnlyList Layers { get; } + + public string Outcome { get; } + + public PoolFunnelTrace Trace { get; } + + public PoolFunnelLayerResult LastLayer => Layers.Count == 0 ? null : Layers[Layers.Count - 1]; + } +} diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs.meta b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs.meta new file mode 100644 index 0000000..a6a3676 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelModels.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 865a911ca10540418dcf5d9e19dbb2df +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs new file mode 100644 index 0000000..3fb728d --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FishDice.LuckyDice +{ + public sealed class PoolFunnelRandomService + { + private const int MaxLayerVisits = 32; + + private readonly IRandomSource _random; + private readonly IRandomPoolStateStore _stateStore; + private readonly ILayerAvailabilityPolicy _availabilityPolicy; + private readonly IResultWeightCalculator _weightCalculator; + private readonly IPoolFunnelActionDispatcher _actionDispatcher; + private readonly IPoolSettlementStrategy _settlementStrategy; + private readonly IFunnelDecisionStrategy _decisionStrategy; + + public PoolFunnelRandomService( + IRandomSource random, + IRandomPoolStateStore stateStore, + ILayerAvailabilityPolicy availabilityPolicy, + IResultWeightCalculator weightCalculator, + IPoolFunnelActionDispatcher actionDispatcher, + IPoolSettlementStrategy settlementStrategy, + IFunnelDecisionStrategy decisionStrategy) + { + _random = random ?? throw new ArgumentNullException(nameof(random)); + _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); + _availabilityPolicy = availabilityPolicy ?? new AlwaysAvailableLayerPolicy(); + _weightCalculator = weightCalculator ?? new LinearLuckValueWeightCalculator(); + _actionDispatcher = actionDispatcher ?? new NoOpPoolFunnelActionDispatcher(); + _settlementStrategy = settlementStrategy ?? new DeltaPoolSettlementStrategy(); + _decisionStrategy = decisionStrategy ?? new ConfigFunnelDecisionStrategy(); + } + + public static PoolFunnelRandomService CreateDefault(IRandomSource random, IRandomPoolStateStore stateStore) + { + return new PoolFunnelRandomService( + random, + stateStore, + new AlwaysAvailableLayerPolicy(), + new LinearLuckValueWeightCalculator(), + new NoOpPoolFunnelActionDispatcher(), + new DeltaPoolSettlementStrategy(), + new ConfigFunnelDecisionStrategy()); + } + + public PoolFunnelRunResult Execute(RandomPoolDefinition pool, PoolFunnelRequest request) + { + if (pool == null) + { + throw new ArgumentNullException(nameof(pool)); + } + + if (request == null) + { + throw new ArgumentNullException(nameof(request)); + } + + var context = new PoolFunnelContext(pool, request); + var unavailableEntryLayerIds = new List(); + var entryLayer = ResolveEntryLayer(pool, context, unavailableEntryLayerIds); + var layerResults = new List(); + + if (entryLayer == null) + { + return CreateResult(request, pool, null, unavailableEntryLayerIds, layerResults, "NoAvailableEntryLayer"); + } + + var currentLayer = entryLayer; + for (var visit = 0; visit < MaxLayerVisits && currentLayer != null; visit++) + { + var layerResult = RollLayer(pool, currentLayer, context); + layerResults.Add(layerResult); + + if (layerResult.Decision.DecisionType != PoolFunnelDecisionType.NextLayer) + { + return CreateResult(request, pool, entryLayer.LayerId, unavailableEntryLayerIds, layerResults, layerResult.Outcome); + } + + RandomLayerDefinition nextLayer; + if (!pool.TryGetLayer(layerResult.Decision.NextLayerId, out nextLayer) || + !_availabilityPolicy.IsLayerAvailable(nextLayer, context)) + { + return CreateResult(request, pool, entryLayer.LayerId, unavailableEntryLayerIds, layerResults, "NextLayerUnavailable"); + } + + currentLayer = nextLayer; + } + + return CreateResult(request, pool, entryLayer.LayerId, unavailableEntryLayerIds, layerResults, "MaxLayerVisitsReached"); + } + + private RandomLayerDefinition ResolveEntryLayer( + RandomPoolDefinition pool, + PoolFunnelContext context, + List unavailableEntryLayerIds) + { + var available = new List(); + foreach (var entryLayerId in pool.EntryLayerIds) + { + var layer = pool.GetLayer(entryLayerId); + if (_availabilityPolicy.IsLayerAvailable(layer, context)) + { + available.Add(layer); + continue; + } + + unavailableEntryLayerIds.Add(layer.LayerId); + } + + return available + .OrderByDescending(layer => layer.Priority) + .ThenBy(layer => layer.LayerId, StringComparer.Ordinal) + .FirstOrDefault(); + } + + private PoolFunnelLayerResult RollLayer( + RandomPoolDefinition pool, + RandomLayerDefinition layer, + PoolFunnelContext context) + { + var stateBefore = _stateStore.Load(pool.PoolId, layer.LayerId); + var allWeights = layer.Results + .Select(result => new WeightedPoolFunnelResult( + result.ResultKey, + result.Priority, + _weightCalculator.CalculateWeight(result, stateBefore, context))) + .ToArray(); + var weightedResults = layer.Results + .Zip(allWeights, (result, weight) => new WeightedCandidate(result, weight.Weight)) + .Where(item => item.Weight > 0) + .ToArray(); + + if (weightedResults.Length == 0) + { + return new PoolFunnelLayerResult( + layer.LayerId, + stateBefore, + stateBefore, + allWeights, + null, + null, + null, + PoolFunnelDecision.Stop(), + "NoWeightedResult"); + } + + var topPriority = weightedResults.Max(item => item.Result.Priority); + var candidates = weightedResults + .Where(item => item.Result.Priority == topPriority) + .ToArray(); + var totalWeight = candidates.Sum(item => item.Weight); + var randomValue = _random.Next(0, totalWeight); + var selected = PickWeighted(candidates, randomValue); + var actionResults = _actionDispatcher.Dispatch(layer, selected.Result, context); + var stateAfter = _settlementStrategy.Settle(stateBefore, selected.Result, context); + _stateStore.Save(stateAfter); + + var decision = _decisionStrategy.Decide(layer, selected.Result, stateAfter, context); + var outcome = decision.DecisionType == PoolFunnelDecisionType.NextLayer ? "NextLayer" : "Stopped"; + + return new PoolFunnelLayerResult( + layer.LayerId, + stateBefore, + stateAfter, + allWeights, + randomValue, + selected.Result, + actionResults, + decision, + outcome); + } + + private static WeightedCandidate PickWeighted(IReadOnlyList candidates, int randomValue) + { + var cursor = 0; + foreach (var candidate in candidates) + { + cursor += candidate.Weight; + if (randomValue < cursor) + { + return candidate; + } + } + + return candidates[candidates.Count - 1]; + } + + private sealed class WeightedCandidate + { + public WeightedCandidate(RandomResultDefinition result, int weight) + { + Result = result; + Weight = weight; + } + + public RandomResultDefinition Result { get; } + + public int Weight { get; } + } + + private static PoolFunnelRunResult CreateResult( + PoolFunnelRequest request, + RandomPoolDefinition pool, + string selectedEntryLayerId, + IReadOnlyList unavailableEntryLayerIds, + IReadOnlyList layerResults, + string outcome) + { + var trace = new PoolFunnelTrace( + request.SessionId, + pool.PoolId, + selectedEntryLayerId, + unavailableEntryLayerIds, + layerResults); + + return new PoolFunnelRunResult( + request.SessionId, + pool.PoolId, + layerResults, + outcome, + trace); + } + } +} diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs.meta b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs.meta new file mode 100644 index 0000000..1558235 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelRandomService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 7ccfc46ad8d04fa58a00d4530f5aaf99 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs new file mode 100644 index 0000000..7cf3df9 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace FishDice.LuckyDice +{ + public interface IRandomPoolStateStore + { + RandomPoolState Load(string poolId, string layerId); + + void Save(RandomPoolState state); + } + + public sealed class InMemoryRandomPoolStateStore : IRandomPoolStateStore + { + private readonly Dictionary _states = + new Dictionary(StringComparer.Ordinal); + + public RandomPoolState Load(string poolId, string layerId) + { + RandomPoolState state; + return _states.TryGetValue(ToKey(poolId, layerId), out state) + ? state + : new RandomPoolState(poolId, layerId, luckValue: 0, rollCount: 0); + } + + public void Save(RandomPoolState state) + { + if (state == null) + { + throw new ArgumentNullException(nameof(state)); + } + + _states[ToKey(state.PoolId, state.LayerId)] = state; + } + + public static string ToKey(string poolId, string layerId) + { + return poolId + "|" + layerId; + } + } +} diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs.meta b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs.meta new file mode 100644 index 0000000..4e51a17 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStateStore.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 4056383b32f5492c8437de66cda54f4e +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs new file mode 100644 index 0000000..b3b2009 --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace FishDice.LuckyDice +{ + public interface ILayerAvailabilityPolicy + { + bool IsLayerAvailable(RandomLayerDefinition layer, PoolFunnelContext context); + } + + public interface IResultWeightCalculator + { + int CalculateWeight(RandomResultDefinition result, RandomPoolState state, PoolFunnelContext context); + } + + public interface IPoolSettlementStrategy + { + RandomPoolState Settle( + RandomPoolState stateBefore, + RandomResultDefinition selectedResult, + PoolFunnelContext context); + } + + public interface IFunnelDecisionStrategy + { + PoolFunnelDecision Decide( + RandomLayerDefinition layer, + RandomResultDefinition selectedResult, + RandomPoolState stateAfter, + PoolFunnelContext context); + } + + public interface IPoolFunnelActionDispatcher + { + IReadOnlyList Dispatch( + RandomLayerDefinition layer, + RandomResultDefinition selectedResult, + PoolFunnelContext context); + } + + public sealed class AlwaysAvailableLayerPolicy : ILayerAvailabilityPolicy + { + public bool IsLayerAvailable(RandomLayerDefinition layer, PoolFunnelContext context) + { + return true; + } + } + + public sealed class LinearLuckValueWeightCalculator : IResultWeightCalculator + { + public int CalculateWeight(RandomResultDefinition result, RandomPoolState state, PoolFunnelContext context) + { + if (result == null) + { + throw new ArgumentNullException(nameof(result)); + } + + if (state == null) + { + throw new ArgumentNullException(nameof(state)); + } + + return Math.Max(0, result.BaseWeight + result.LuckValueWeightFactor * state.LuckValue); + } + } + + public sealed class DeltaPoolSettlementStrategy : IPoolSettlementStrategy + { + public RandomPoolState Settle( + RandomPoolState stateBefore, + RandomResultDefinition selectedResult, + PoolFunnelContext context) + { + if (stateBefore == null) + { + throw new ArgumentNullException(nameof(stateBefore)); + } + + if (selectedResult == null) + { + throw new ArgumentNullException(nameof(selectedResult)); + } + + return stateBefore.With( + stateBefore.LuckValue + selectedResult.LuckValueDelta, + stateBefore.RollCount + 1); + } + } + + public sealed class ConfigFunnelDecisionStrategy : IFunnelDecisionStrategy + { + public PoolFunnelDecision Decide( + RandomLayerDefinition layer, + RandomResultDefinition selectedResult, + RandomPoolState stateAfter, + PoolFunnelContext context) + { + if (selectedResult == null) + { + return PoolFunnelDecision.Stop(); + } + + return selectedResult.Decision ?? PoolFunnelDecision.Stop(); + } + } + + public sealed class NoOpPoolFunnelActionDispatcher : IPoolFunnelActionDispatcher + { + public IReadOnlyList Dispatch( + RandomLayerDefinition layer, + RandomResultDefinition selectedResult, + PoolFunnelContext context) + { + if (selectedResult == null || selectedResult.Actions == null) + { + return Array.Empty(); + } + + return selectedResult.Actions + .Select(action => RollActionResult.Succeeded(action.ActionType, "PoolFunnelActionQueued")) + .ToArray(); + } + } +} diff --git a/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs.meta b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs.meta new file mode 100644 index 0000000..99dd64e --- /dev/null +++ b/FishDice/Assets/FishDice/Runtime/LuckyDice/PoolFunnel/PoolFunnelStrategies.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0aef09b0e6244bb39184124cce2bbd5c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs b/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs new file mode 100644 index 0000000..16a4889 --- /dev/null +++ b/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs @@ -0,0 +1,249 @@ +using System.Collections.Generic; +using System.Linq; +using FishDice.LuckyDice; +using NUnit.Framework; + +namespace FishDice.Tests.LuckyDice +{ + public sealed class PoolFunnelRandomTests + { + [Test] + public void Entry_resolution_skips_closed_commercial_pack_layer() + { + var store = new InMemoryRandomPoolStateStore(); + var service = TestService( + new FixedRandomSource(0), + store, + new KeyedLayerAvailabilityPolicy("commercial_open", false)); + var pool = new RandomPoolDefinition( + "main_pool", + new[] { "commercial_pack", "normal_reward" }, + new[] + { + Layer("commercial_pack", 100, "commercial_open", Result("pack_entry", 0, 1, 0, 1, PoolFunnelDecision.Stop())), + Layer("normal_reward", 10, null, Result("coins", 0, 1, 0, 1, PoolFunnelDecision.Stop())) + }); + + var result = service.Execute(pool, new PoolFunnelRequest("session-entry", "normal_roll")); + + Assert.That(result.Trace.SelectedEntryLayerId, Is.EqualTo("normal_reward")); + Assert.That(result.Trace.UnavailableEntryLayerIds, Is.EqualTo(new[] { "commercial_pack" })); + Assert.That(result.LastLayer.SelectedResult.ResultKey, Is.EqualTo("coins")); + Assert.That(store.Load("main_pool", "commercial_pack").LuckValue, Is.EqualTo(0)); + } + + [Test] + public void Commercial_pack_entry_can_enter_child_layer_and_child_uses_result_priority() + { + var service = TestService(new FixedRandomSource(0, 0), new InMemoryRandomPoolStateStore()); + var pool = new RandomPoolDefinition( + "main_pool", + new[] { "commercial_pack" }, + new[] + { + Layer( + "commercial_pack", + 100, + null, + Result("enter_pack_picker", 0, 1, 0, 0, PoolFunnelDecision.NextLayer("pack_picker"))), + Layer( + "pack_picker", + 0, + null, + Result("standard_pack", 1, 999, 0, -3, PoolFunnelDecision.Stop()), + Result("premium_pack", 5, 1, 0, -7, PoolFunnelDecision.Stop())) + }); + + var result = service.Execute(pool, new PoolFunnelRequest("session-pack", "normal_roll")); + + Assert.That(result.Layers.Select(layer => layer.LayerId), Is.EqualTo(new[] { "commercial_pack", "pack_picker" })); + Assert.That(result.LastLayer.SelectedResult.ResultKey, Is.EqualTo("premium_pack")); + Assert.That(result.LastLayer.Weights.Select(weight => weight.ResultKey), Is.EqualTo(new[] { "standard_pack", "premium_pack" })); + } + + [Test] + public void Dynamic_weight_uses_luckValue_before_settlement() + { + var store = new InMemoryRandomPoolStateStore(); + store.Save(new RandomPoolState("main_pool", "jackpot_layer", luckValue: 5, rollCount: 5)); + var service = TestService(new FixedRandomSource(10), store); + var pool = new RandomPoolDefinition( + "main_pool", + new[] { "jackpot_layer" }, + new[] + { + Layer( + "jackpot_layer", + 0, + null, + Result("small_reward", 0, 10, 0, 1, PoolFunnelDecision.Stop()), + Result("jackpot", 0, 1, 2, -5, PoolFunnelDecision.Stop())) + }); + + var result = service.Execute(pool, new PoolFunnelRequest("session-weight", "normal_roll")); + + Assert.That(result.LastLayer.Weights.Single(weight => weight.ResultKey == "jackpot").Weight, Is.EqualTo(11)); + Assert.That(result.LastLayer.RandomValue, Is.EqualTo(10)); + Assert.That(result.LastLayer.SelectedResult.ResultKey, Is.EqualTo("jackpot")); + Assert.That(result.LastLayer.StateAfter.LuckValue, Is.EqualTo(0)); + Assert.That(result.LastLayer.StateAfter.RollCount, Is.EqualTo(6)); + } + + [Test] + public void Different_pack_results_can_consume_different_luckValue_amounts() + { + var store = new InMemoryRandomPoolStateStore(); + store.Save(new RandomPoolState("main_pool", "pack_picker", luckValue: 20, rollCount: 2)); + var service = TestService(new FixedRandomSource(1), store); + var pool = new RandomPoolDefinition( + "main_pool", + new[] { "pack_picker" }, + new[] + { + Layer( + "pack_picker", + 0, + null, + Result("standard_pack", 0, 1, 0, -3, PoolFunnelDecision.Stop()), + Result("premium_pack", 0, 1, 0, -7, PoolFunnelDecision.Stop())) + }); + + var result = service.Execute(pool, new PoolFunnelRequest("session-consume", "normal_roll")); + + Assert.That(result.LastLayer.SelectedResult.ResultKey, Is.EqualTo("premium_pack")); + Assert.That(store.Load("main_pool", "pack_picker").LuckValue, Is.EqualTo(13)); + Assert.That(store.Load("main_pool", "pack_picker").RollCount, Is.EqualTo(3)); + } + + [Test] + public void Selected_result_actions_are_dispatched_into_layer_result() + { + var service = TestService(new FixedRandomSource(0), new InMemoryRandomPoolStateStore()); + var pool = new RandomPoolDefinition( + "main_pool", + new[] { "action_layer" }, + new[] + { + Layer( + "action_layer", + 0, + null, + new RandomResultDefinition( + "show_pack", + priority: 0, + baseWeight: 1, + luckValueWeightFactor: 0, + luckValueDelta: 0, + decision: PoolFunnelDecision.Stop(), + actions: new[] { new RollActionSpec("show_commercial_pack") })) + }); + + var result = service.Execute(pool, new PoolFunnelRequest("session-actions", "normal_roll")); + + Assert.That(result.LastLayer.ActionResults.Select(action => action.ActionType), Is.EqualTo(new[] { "show_commercial_pack" })); + Assert.That(result.LastLayer.ActionResults.Single().Outcome, Is.EqualTo("PoolFunnelActionQueued")); + } + + [Test] + public void Local_state_is_isolated_by_poolId_and_layerId() + { + var store = new InMemoryRandomPoolStateStore(); + store.Save(new RandomPoolState("pool_a", "shared_layer", luckValue: 4, rollCount: 1)); + store.Save(new RandomPoolState("pool_b", "shared_layer", luckValue: 99, rollCount: 9)); + var service = TestService(new FixedRandomSource(0), store); + var pool = new RandomPoolDefinition( + "pool_a", + new[] { "shared_layer" }, + new[] + { + Layer("shared_layer", 0, null, Result("reward", 0, 1, 0, 2, PoolFunnelDecision.Stop())) + }); + + service.Execute(pool, new PoolFunnelRequest("session-state", "normal_roll")); + + Assert.That(store.Load("pool_a", "shared_layer").LuckValue, Is.EqualTo(6)); + Assert.That(store.Load("pool_a", "shared_layer").RollCount, Is.EqualTo(2)); + Assert.That(store.Load("pool_b", "shared_layer").LuckValue, Is.EqualTo(99)); + Assert.That(store.Load("pool_b", "shared_layer").RollCount, Is.EqualTo(9)); + } + + private static PoolFunnelRandomService TestService( + IRandomSource random, + IRandomPoolStateStore store, + ILayerAvailabilityPolicy availability = null) + { + return new PoolFunnelRandomService( + random, + store, + availability ?? new AlwaysAvailableLayerPolicy(), + new LinearLuckValueWeightCalculator(), + new NoOpPoolFunnelActionDispatcher(), + new DeltaPoolSettlementStrategy(), + new ConfigFunnelDecisionStrategy()); + } + + private static RandomLayerDefinition Layer( + string layerId, + int priority, + string availabilityKey, + params RandomResultDefinition[] results) + { + return new RandomLayerDefinition(layerId, priority, availabilityKey, results); + } + + private static RandomResultDefinition Result( + string resultKey, + int priority, + int baseWeight, + int luckValueWeightFactor, + int luckValueDelta, + PoolFunnelDecision decision) + { + return new RandomResultDefinition( + resultKey, + priority, + baseWeight, + luckValueWeightFactor, + luckValueDelta, + decision); + } + + private sealed class KeyedLayerAvailabilityPolicy : ILayerAvailabilityPolicy + { + private readonly Dictionary _availability; + + public KeyedLayerAvailabilityPolicy(string key, bool isAvailable) + { + _availability = new Dictionary { { key, isAvailable } }; + } + + public bool IsLayerAvailable(RandomLayerDefinition layer, PoolFunnelContext context) + { + if (string.IsNullOrEmpty(layer.AvailabilityKey)) + { + return true; + } + + bool isAvailable; + return _availability.TryGetValue(layer.AvailabilityKey, out isAvailable) && isAvailable; + } + } + + private sealed class FixedRandomSource : IRandomSource + { + private readonly Queue _values; + + public FixedRandomSource(params int[] values) + { + _values = new Queue(values); + } + + public int Next(int minInclusive, int maxExclusive) + { + var value = _values.Dequeue(); + Assert.That(value, Is.InRange(minInclusive, maxExclusive - 1)); + return value; + } + } + } +} diff --git a/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs.meta b/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs.meta new file mode 100644 index 0000000..1ed880a --- /dev/null +++ b/FishDice/Assets/FishDice/Tests/EditMode/LuckyDice/PoolFunnelRandomTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: e6eaadad25e4415488daeb07b3910682 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: