feat(lucky-dice): 添加 PoolFunnel 随机服务

This commit is contained in:
JSD\13999
2026-06-25 10:45:03 +08:00
parent badd9e820f
commit 4247782c01
11 changed files with 1057 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 5c5be5cf8dd34b0b80b7fb0f681fbb6d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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];
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 865a911ca10540418dcf5d9e19dbb2df
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ccfc46ad8d04fa58a00d4530f5aaf99
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 4056383b32f5492c8437de66cda54f4e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0aef09b0e6244bb39184124cce2bbd5c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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;
}
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e6eaadad25e4415488daeb07b3910682
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: