using System; using System.Collections.Generic; using KernelPanic.Data; namespace KernelPanic.Meta { /// /// Provides persistent banner pull logic and currency accounting. /// public sealed class GachaService { public const string BeginnerBannerId = "beginner"; public const string StandardBannerId = "standard"; public const string LimitedBannerId = "limited"; public const int BeginnerMaxPulls = 50; public const int BeginnerSinglePullCost = 1; public const int BeginnerTenPullCost = 10; public const int FourStarHardPity = 10; public const int FiveStarSoftPityStart = 66; public const int FiveStarHardPity = 80; public const int EntropyPerCommit = 100; public const int RootCreditsToEntropy = 1; public const int TestRootCreditsBalance = 10000; private const double FourStarBaseChance = 0.12d; private const double FiveStarBaseChance = 0.008d; private const double FiveStarSoftPityStep = 0.07d; private const double DistroChanceOnFeaturedTier = 0.5d; private const double LimitedStandardFiveStarChance = 1d / 3d; private readonly List _bannerPool = new(); private readonly Dictionary _currencyBalances = new(); private readonly Random _random; private GachaBannerState _beginnerState = new(BeginnerBannerId); public GachaService() : this(new Random()) { } public GachaService(Random random) { this._random = random ?? new Random(); _currencyBalances[GachaCurrencyType.StandardPull] = 0; } public int PullTokens => GetCurrencyBalance(GachaCurrencyType.StandardPull); public int Commits => PullTokens; public int RootCredits { get; private set; } public IReadOnlyList BannerPool => _bannerPool; public GachaBannerState BeginnerState => _beginnerState; public bool IsBeginnerBannerAvailable => !_beginnerState.exhausted && _beginnerState.totalPulls < BeginnerMaxPulls && _bannerPool.Count > 0; public event Action BannerPoolChanged; public event Action Changed; public void LoadProgress(SaveData data) { if (data == null) { return; } data.EnsureLists(); RootCredits = Math.Max(0, data.rootCredits); SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.commitsBalance); _beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId); _beginnerState.bannerId = BeginnerBannerId; _beginnerState.EnsureLists(); if (_beginnerState.totalPulls >= BeginnerMaxPulls) { _beginnerState.exhausted = true; } _beginnerState.fiveStarPityCounter = Math.Max(0, _beginnerState.fiveStarPityCounter); Changed?.Invoke(); } public void WriteProgress(SaveData data) { if (data == null) { return; } data.EnsureLists(); data.rootCredits = RootCredits; data.commitsBalance = GetCurrencyBalance(GachaCurrencyType.StandardPull); data.beginnerBannerState = CloneState(_beginnerState); } public int GetCurrencyBalance(GachaCurrencyType currencyType) { return _currencyBalances.TryGetValue(currencyType, out int balance) ? balance : 0; } public void SetCurrencyBalance(GachaCurrencyType currencyType, int amount) { SetCurrencyBalanceSilently(currencyType, amount); Changed?.Invoke(); } public void AddCurrency(GachaCurrencyType currencyType, int amount) { if (amount <= 0) { return; } SetCurrencyBalance(currencyType, GetCurrencyBalance(currencyType) + amount); } public void AddRootCredits(int amount) { if (amount <= 0) { return; } RootCredits += amount; Changed?.Invoke(); } public bool ConvertRootCreditsToEntropy(EntropyWallet wallet, int amount, out string failureReason) { return BuyEntropy(wallet, amount, out failureReason); } public bool BuyEntropy(EntropyWallet wallet, int rootCredits, out string failureReason) { if (wallet == null) { failureReason = "entropy wallet unavailable"; return false; } if (rootCredits <= 0) { failureReason = "no Root Credits selected"; return false; } if (RootCredits < rootCredits) { failureReason = $"need {rootCredits} Root Credits"; return false; } RootCredits -= rootCredits; wallet.Add(rootCredits * RootCreditsToEntropy); failureReason = null; Changed?.Invoke(); return true; } public void AddToBannerPool(DistroDefinition distro) { if (distro == null || _bannerPool.Contains(distro)) { return; } _bannerPool.Add(distro); BannerPoolChanged?.Invoke(); Changed?.Invoke(); } public void SetBeginnerGuaranteedDistros(IReadOnlyList distros) { _beginnerState.guaranteedDistroIds.Clear(); if (distros != null) { for (int i = 0; i < distros.Count; i++) { DistroDefinition distro = distros[i]; if (distro != null && !string.IsNullOrWhiteSpace(distro.Id)) { _beginnerState.guaranteedDistroIds.Add(distro.Id); } } } Changed?.Invoke(); } public int GetBeginnerPullCost(int pullCount) { return pullCount == 10 ? BeginnerTenPullCost : BeginnerSinglePullCost; } public bool CanPullBeginner(int pullCount, out string failureReason) { return CanAffordPull(pullCount, null, PullPaymentSource.Commits, out failureReason); } public bool CanAffordPull(int pullCount, EntropyWallet wallet, PullPaymentSource source, out string failureReason) { if (pullCount != 1 && pullCount != 10) { failureReason = "only single and ten pulls are supported"; return false; } if (!IsBeginnerBannerAvailable) { failureReason = "beginner banner unavailable"; return false; } int remainingPulls = BeginnerMaxPulls - _beginnerState.totalPulls; if (pullCount > remainingPulls) { failureReason = $"only {remainingPulls} beginner pull(s) remain"; return false; } int cost = GetBeginnerPullCost(pullCount); if (source == PullPaymentSource.Commits && GetCurrencyBalance(GachaCurrencyType.StandardPull) < cost) { failureReason = $"need {cost} {FormatCurrencyName(GachaCurrencyType.StandardPull)}"; return false; } int entropyCost = cost * EntropyPerCommit; if (source == PullPaymentSource.Entropy && (wallet == null || wallet.Balance < entropyCost)) { failureReason = $"need {entropyCost} Entropy"; return false; } failureReason = null; return true; } public bool PayForPull(int pullCount, EntropyWallet wallet, PullPaymentSource source, out int commitsSpent, out int entropySpent, out string failureReason) { commitsSpent = 0; entropySpent = 0; if (!CanAffordPull(pullCount, wallet, source, out failureReason)) { return false; } int cost = GetBeginnerPullCost(pullCount); if (source == PullPaymentSource.Commits) { commitsSpent = cost; SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, GetCurrencyBalance(GachaCurrencyType.StandardPull) - commitsSpent); } else { entropySpent = cost * EntropyPerCommit; wallet.Spend(entropySpent); } return true; } public int GetMissingPullTokens(GachaCurrencyType currencyType, int pullCost) { return Math.Max(0, pullCost - GetCurrencyBalance(currencyType)); } public GachaPullResult PerformBeginnerPull(int pullCount) { return PerformBeginnerPull(pullCount, null, PullPaymentSource.Commits); } public GachaPullResult PerformBeginnerPull(int pullCount, EntropyWallet wallet, int entropyTokenCount) { PullPaymentSource source = entropyTokenCount > 0 ? PullPaymentSource.Entropy : PullPaymentSource.Commits; return PerformBeginnerPull(pullCount, wallet, source); } public GachaPullResult PerformBeginnerPull(int pullCount, EntropyWallet wallet, PullPaymentSource source) { if (!CanAffordPull(pullCount, wallet, source, out string failureReason)) { return GachaPullResult.Failed(BeginnerBannerId, failureReason); } if (!PayForPull(pullCount, wallet, source, out int commitSpend, out int entropySpent, out failureReason)) { return GachaPullResult.Failed(BeginnerBannerId, failureReason); } List rewards = new(); for (int i = 0; i < pullCount; i++) { _beginnerState.totalPulls++; rewards.Add(RollBeginnerReward(_beginnerState.totalPulls)); } if (_beginnerState.totalPulls >= BeginnerMaxPulls) { _beginnerState.exhausted = true; } Changed?.Invoke(); return new GachaPullResult(true, BeginnerBannerId, GachaCurrencyType.StandardPull, commitSpend, entropySpent, rewards, null); } public static string FormatCurrencyName(GachaCurrencyType currencyType) { return currencyType switch { _ => "Commits" }; } public static double GetFiveStarChance(GachaBannerState state) { return GetFiveStarChance(state == null ? 0 : state.fiveStarPityCounter); } public static double GetFiveStarChance(int fiveStarPityCounter) { int pityPullNumber = Math.Max(0, fiveStarPityCounter) + 1; if (pityPullNumber >= FiveStarHardPity) { return 1d; } if (pityPullNumber < FiveStarSoftPityStart) { return FiveStarBaseChance; } int softPitySteps = pityPullNumber - FiveStarSoftPityStart + 1; return Math.Min(1d, FiveStarBaseChance + softPitySteps * FiveStarSoftPityStep); } public GachaPullResult PerformPull(EntropyWallet wallet, int cost) { return PerformBeginnerPull(1); } private GachaReward RollBeginnerReward(int pullNumber) { if (pullNumber == 20) { AdvanceFiveStarPityAfterNonFiveStar(_beginnerState); return RewardGuaranteedBeginnerDistro(0, "20-pull starter guarantee"); } if (pullNumber == 40) { AdvanceFiveStarPityAfterNonFiveStar(_beginnerState); return RewardGuaranteedBeginnerDistro(1, "40-pull starter guarantee"); } if (pullNumber == 50) { _beginnerState.pityCounter = 0; return GachaReward.FutureStandardFiveStar("future standard 5-star distro", "50-pull beginner capstone"); } if (TryRollFiveStar(_beginnerState, out bool fiveStarPityTriggered)) { return RewardRandomFiveStar(fiveStarPityTriggered); } bool pityTriggered = _beginnerState.pityCounter >= FourStarHardPity - 1; bool hitFourStarOrCharacter = pityTriggered || _random.NextDouble() < FourStarBaseChance; if (!hitFourStarOrCharacter) { _beginnerState.pityCounter++; return GachaReward.Package(3, "3-star package", false, false); } _beginnerState.pityCounter = 0; if (_bannerPool.Count > 0 && _random.NextDouble() < DistroChanceOnFeaturedTier) { DistroDefinition distro = _bannerPool[_random.Next(_bannerPool.Count)]; return GachaReward.DistroReward(distro, 4, pityTriggered, false); } return GachaReward.Package(4, "4-star package", pityTriggered, false); } private bool TryRollFiveStar(GachaBannerState state, out bool pityTriggered) { state ??= _beginnerState; int pityPullNumber = Math.Max(0, state.fiveStarPityCounter) + 1; pityTriggered = pityPullNumber >= FiveStarSoftPityStart; double chance = GetFiveStarChance(state); bool hit = chance >= 1d || _random.NextDouble() < chance; if (!hit) { state.fiveStarPityCounter++; return false; } state.fiveStarPityCounter = 0; state.pityCounter = 0; return true; } private static void AdvanceFiveStarPityAfterNonFiveStar(GachaBannerState state) { if (state != null) { state.fiveStarPityCounter++; } } private GachaReward RewardRandomFiveStar(bool pityTriggered) { if (_bannerPool.Count == 0 || _random.NextDouble() >= DistroChanceOnFeaturedTier) { return GachaReward.Package(5, "5-star package", pityTriggered, false); } DistroDefinition distro = _bannerPool.Count == 0 ? null : _bannerPool[_random.Next(_bannerPool.Count)]; return GachaReward.DistroReward(distro, 5, pityTriggered, false); } public bool ResolveFeaturedTierIsDistro() { return _random.NextDouble() < DistroChanceOnFeaturedTier; } public bool ResolveLimitedFiveStarUsesStandardPool(GachaBannerState limitedState) { if (limitedState == null) { return false; } if (limitedState.featuredFiveStarGuaranteed) { limitedState.featuredFiveStarGuaranteed = false; return false; } bool useStandardPool = _random.NextDouble() < LimitedStandardFiveStarChance; limitedState.featuredFiveStarGuaranteed = useStandardPool; return useStandardPool; } private GachaReward RewardGuaranteedBeginnerDistro(int guaranteeIndex, string reason) { _beginnerState.pityCounter = 0; DistroDefinition distro = null; if (guaranteeIndex >= 0 && guaranteeIndex < _beginnerState.guaranteedDistroIds.Count) { distro = FindBannerPoolDistro(_beginnerState.guaranteedDistroIds[guaranteeIndex]); } distro ??= _bannerPool.Count == 0 ? null : _bannerPool[Math.Min(guaranteeIndex, _bannerPool.Count - 1)]; return distro == null ? GachaReward.Package(4, "4-star package", true, true) : GachaReward.DistroReward(distro, 4, true, true, reason); } private DistroDefinition FindBannerPoolDistro(string id) { if (string.IsNullOrWhiteSpace(id)) { return null; } for (int i = 0; i < _bannerPool.Count; i++) { DistroDefinition distro = _bannerPool[i]; if (distro != null && string.Equals(distro.Id, id, StringComparison.OrdinalIgnoreCase)) { return distro; } } return null; } private void SetCurrencyBalanceSilently(GachaCurrencyType currencyType, int amount) { _currencyBalances[currencyType] = Math.Max(0, amount); } private static GachaBannerState CloneState(GachaBannerState state) { state ??= new GachaBannerState(BeginnerBannerId); state.EnsureLists(); GachaBannerState clone = new(state.bannerId) { totalPulls = state.totalPulls, pityCounter = state.pityCounter, fiveStarPityCounter = state.fiveStarPityCounter, exhausted = state.exhausted, featuredFiveStarGuaranteed = state.featuredFiveStarGuaranteed }; clone.guaranteedDistroIds.AddRange(state.guaranteedDistroIds); return clone; } } /// /// Describes one reward entry from one pull. /// public readonly struct GachaReward { private GachaReward( GachaRewardType rewardType, int starRating, string displayName, DistroDefinition distro, bool pityTriggered, bool guaranteed, string guaranteeReason) { RewardType = rewardType; StarRating = starRating; DisplayName = displayName; Distro = distro; PityTriggered = pityTriggered; Guaranteed = guaranteed; GuaranteeReason = guaranteeReason; } public GachaRewardType RewardType { get; } public int StarRating { get; } public string DisplayName { get; } public DistroDefinition Distro { get; } public bool PityTriggered { get; } public bool Guaranteed { get; } public string GuaranteeReason { get; } public static GachaReward Package(int starRating, string displayName, bool pityTriggered, bool guaranteed) { return new GachaReward(GachaRewardType.Package, starRating, displayName, null, pityTriggered, guaranteed, null); } public static GachaReward DistroReward(DistroDefinition distro, int starRating, bool pityTriggered, bool guaranteed, string guaranteeReason = null) { string displayName = distro == null ? "unknown distro" : DistroDisplayName(distro); return new GachaReward(GachaRewardType.Distro, starRating, displayName, distro, pityTriggered, guaranteed, guaranteeReason); } public static GachaReward FutureStandardFiveStar(string displayName, string guaranteeReason) { return new GachaReward(GachaRewardType.FutureStandardFiveStarDistro, 5, displayName, null, true, true, guaranteeReason); } private static string DistroDisplayName(DistroDefinition distro) { return string.IsNullOrWhiteSpace(distro.DisplayName) ? distro.name : distro.DisplayName; } } /// /// Represents the result of a banner pull request. /// public readonly struct GachaPullResult { public GachaPullResult( bool success, string bannerId, GachaCurrencyType currencyType, int currencySpent, int entropySpent, IReadOnlyList rewards, string failureReason) { Success = success; BannerId = bannerId; CurrencyType = currencyType; CurrencySpent = currencySpent; EntropySpent = entropySpent; Rewards = rewards ?? Array.Empty(); FailureReason = failureReason; UnlockId = Rewards.Count > 0 && Rewards[0].Distro != null ? Rewards[0].Distro.Id : null; } public bool Success { get; } public string BannerId { get; } public GachaCurrencyType CurrencyType { get; } public int CurrencySpent { get; } public int EntropySpent { get; } public IReadOnlyList Rewards { get; } public string FailureReason { get; } public string UnlockId { get; } public static GachaPullResult Failed(string bannerId, string reason) { return new GachaPullResult(false, bannerId, GachaCurrencyType.StandardPull, 0, 0, Array.Empty(), reason); } } }