feat(lucky-dice): 添加 PoolFunnel 随机服务
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5c5be5cf8dd34b0b80b7fb0f681fbb6d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string, RandomLayerDefinition> _layersById;
|
||||
|
||||
public RandomPoolDefinition(
|
||||
string poolId,
|
||||
IReadOnlyList<string> entryLayerIds,
|
||||
IReadOnlyList<RandomLayerDefinition> 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<string> EntryLayerIds { get; }
|
||||
|
||||
public IReadOnlyList<RandomLayerDefinition> 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<RandomResultDefinition> 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<RandomResultDefinition> Results { get; }
|
||||
}
|
||||
|
||||
public sealed class RandomResultDefinition
|
||||
{
|
||||
public RandomResultDefinition(
|
||||
string resultKey,
|
||||
int priority,
|
||||
int baseWeight,
|
||||
int luckValueWeightFactor,
|
||||
int luckValueDelta,
|
||||
PoolFunnelDecision decision,
|
||||
IReadOnlyList<RollActionSpec> 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<RollActionSpec>() : 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<RollActionSpec> 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<WeightedPoolFunnelResult> weights,
|
||||
int? randomValue,
|
||||
RandomResultDefinition selectedResult,
|
||||
IReadOnlyList<RollActionResult> actionResults,
|
||||
PoolFunnelDecision decision,
|
||||
string outcome)
|
||||
{
|
||||
LayerId = layerId;
|
||||
StateBefore = stateBefore;
|
||||
StateAfter = stateAfter;
|
||||
Weights = weights == null ? Array.Empty<WeightedPoolFunnelResult>() : weights.ToArray();
|
||||
RandomValue = randomValue;
|
||||
SelectedResult = selectedResult;
|
||||
ActionResults = actionResults == null ? Array.Empty<RollActionResult>() : actionResults.ToArray();
|
||||
Decision = decision ?? PoolFunnelDecision.Stop();
|
||||
Outcome = outcome;
|
||||
}
|
||||
|
||||
public string LayerId { get; }
|
||||
|
||||
public RandomPoolState StateBefore { get; }
|
||||
|
||||
public RandomPoolState StateAfter { get; }
|
||||
|
||||
public IReadOnlyList<WeightedPoolFunnelResult> Weights { get; }
|
||||
|
||||
public int? RandomValue { get; }
|
||||
|
||||
public RandomResultDefinition SelectedResult { get; }
|
||||
|
||||
public IReadOnlyList<RollActionResult> ActionResults { get; }
|
||||
|
||||
public PoolFunnelDecision Decision { get; }
|
||||
|
||||
public string Outcome { get; }
|
||||
}
|
||||
|
||||
public sealed class PoolFunnelTrace
|
||||
{
|
||||
public PoolFunnelTrace(
|
||||
string sessionId,
|
||||
string poolId,
|
||||
string selectedEntryLayerId,
|
||||
IReadOnlyList<string> unavailableEntryLayerIds,
|
||||
IReadOnlyList<PoolFunnelLayerResult> layers)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
PoolId = poolId;
|
||||
SelectedEntryLayerId = selectedEntryLayerId;
|
||||
UnavailableEntryLayerIds = unavailableEntryLayerIds == null
|
||||
? Array.Empty<string>()
|
||||
: unavailableEntryLayerIds.ToArray();
|
||||
Layers = layers == null ? Array.Empty<PoolFunnelLayerResult>() : layers.ToArray();
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public string PoolId { get; }
|
||||
|
||||
public string SelectedEntryLayerId { get; }
|
||||
|
||||
public IReadOnlyList<string> UnavailableEntryLayerIds { get; }
|
||||
|
||||
public IReadOnlyList<PoolFunnelLayerResult> Layers { get; }
|
||||
}
|
||||
|
||||
public sealed class PoolFunnelRunResult
|
||||
{
|
||||
public PoolFunnelRunResult(
|
||||
string sessionId,
|
||||
string poolId,
|
||||
IReadOnlyList<PoolFunnelLayerResult> layers,
|
||||
string outcome,
|
||||
PoolFunnelTrace trace)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
PoolId = poolId;
|
||||
Layers = layers == null ? Array.Empty<PoolFunnelLayerResult>() : layers.ToArray();
|
||||
Outcome = outcome;
|
||||
Trace = trace;
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
|
||||
public string PoolId { get; }
|
||||
|
||||
public IReadOnlyList<PoolFunnelLayerResult> Layers { get; }
|
||||
|
||||
public string Outcome { get; }
|
||||
|
||||
public PoolFunnelTrace Trace { get; }
|
||||
|
||||
public PoolFunnelLayerResult LastLayer => Layers.Count == 0 ? null : Layers[Layers.Count - 1];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 865a911ca10540418dcf5d9e19dbb2df
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string>();
|
||||
var entryLayer = ResolveEntryLayer(pool, context, unavailableEntryLayerIds);
|
||||
var layerResults = new List<PoolFunnelLayerResult>();
|
||||
|
||||
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<string> unavailableEntryLayerIds)
|
||||
{
|
||||
var available = new List<RandomLayerDefinition>();
|
||||
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<WeightedCandidate> 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<string> unavailableEntryLayerIds,
|
||||
IReadOnlyList<PoolFunnelLayerResult> layerResults,
|
||||
string outcome)
|
||||
{
|
||||
var trace = new PoolFunnelTrace(
|
||||
request.SessionId,
|
||||
pool.PoolId,
|
||||
selectedEntryLayerId,
|
||||
unavailableEntryLayerIds,
|
||||
layerResults);
|
||||
|
||||
return new PoolFunnelRunResult(
|
||||
request.SessionId,
|
||||
pool.PoolId,
|
||||
layerResults,
|
||||
outcome,
|
||||
trace);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7ccfc46ad8d04fa58a00d4530f5aaf99
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string, RandomPoolState> _states =
|
||||
new Dictionary<string, RandomPoolState>(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4056383b32f5492c8437de66cda54f4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<RollActionResult> 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<RollActionResult> Dispatch(
|
||||
RandomLayerDefinition layer,
|
||||
RandomResultDefinition selectedResult,
|
||||
PoolFunnelContext context)
|
||||
{
|
||||
if (selectedResult == null || selectedResult.Actions == null)
|
||||
{
|
||||
return Array.Empty<RollActionResult>();
|
||||
}
|
||||
|
||||
return selectedResult.Actions
|
||||
.Select(action => RollActionResult.Succeeded(action.ActionType, "PoolFunnelActionQueued"))
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aef09b0e6244bb39184124cce2bbd5c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -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<string, bool> _availability;
|
||||
|
||||
public KeyedLayerAvailabilityPolicy(string key, bool isAvailable)
|
||||
{
|
||||
_availability = new Dictionary<string, bool> { { 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<int> _values;
|
||||
|
||||
public FixedRandomSource(params int[] values)
|
||||
{
|
||||
_values = new Queue<int>(values);
|
||||
}
|
||||
|
||||
public int Next(int minInclusive, int maxExclusive)
|
||||
{
|
||||
var value = _values.Dequeue();
|
||||
Assert.That(value, Is.InRange(minInclusive, maxExclusive - 1));
|
||||
return value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e6eaadad25e4415488daeb07b3910682
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user