Implement many UX improvements, packages and their assets and a UI overhaul in collection

This commit is contained in:
2026-07-09 10:18:24 +02:00
parent 61030d84b7
commit 844e81488c
111 changed files with 4480 additions and 258 deletions
+7 -2
View File
@@ -5,7 +5,12 @@ namespace KernelPanic.Meta
/// </summary>
public enum GachaCurrencyType
{
StandardPull,
LimitedPull
StandardPull
}
public enum PullPaymentSource
{
Commits,
Entropy
}
}
+7 -5
View File
@@ -16,9 +16,9 @@ namespace KernelPanic.Meta
public static bool HasPendingPull => pendingPull != null;
public static bool HasCompletedPull => completedPull != null;
public static void SetPending(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, string focusUnitId)
public static void SetPending(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, PackageDatabase packageDatabase, string focusUnitId)
{
pendingPull = new PendingGachaPull(bannerId, pullCount, entropyTokenCount, distroDatabase, focusUnitId);
pendingPull = new PendingGachaPull(bannerId, pullCount, entropyTokenCount, distroDatabase, packageDatabase, focusUnitId);
completedPull = null;
}
@@ -44,12 +44,13 @@ namespace KernelPanic.Meta
public sealed class PendingGachaPull
{
public PendingGachaPull(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, string focusUnitId)
public PendingGachaPull(string bannerId, int pullCount, int entropyTokenCount, DistroDatabase distroDatabase, PackageDatabase packageDatabase, string focusUnitId)
{
BannerId = string.IsNullOrWhiteSpace(bannerId) ? GachaService.BeginnerBannerId : bannerId;
PullCount = Math.Max(1, pullCount);
EntropyTokenCount = Math.Max(0, entropyTokenCount);
DistroDatabase = distroDatabase;
PackageDatabase = packageDatabase;
FocusUnitId = focusUnitId;
}
@@ -57,6 +58,7 @@ namespace KernelPanic.Meta
public int PullCount { get; }
public int EntropyTokenCount { get; }
public DistroDatabase DistroDatabase { get; }
public PackageDatabase PackageDatabase { get; }
public string FocusUnitId { get; }
}
@@ -131,7 +133,7 @@ namespace KernelPanic.Meta
public bool PityTriggered { get; }
public bool IsCharacter => RewardType == GachaRewardType.Distro || RewardType == GachaRewardType.FutureStandardFiveStarDistro;
public string TypeText => IsCharacter ? "character" : "equipment";
public string TypeText => IsCharacter ? "character" : "package";
public string StatusText
{
@@ -173,7 +175,7 @@ namespace KernelPanic.Meta
return "unknown result";
}
if (rewardType != GachaRewardType.Equipment)
if (rewardType != GachaRewardType.Package)
{
return displayName;
}
+1 -1
View File
@@ -5,7 +5,7 @@ namespace KernelPanic.Meta
/// </summary>
public enum GachaRewardType
{
Equipment,
Package,
Distro,
FutureStandardFiveStarDistro
}
+72 -87
View File
@@ -14,11 +14,12 @@ namespace KernelPanic.Meta
public const string LimitedBannerId = "limited";
public const int BeginnerMaxPulls = 50;
public const int BeginnerSinglePullCost = 1;
public const int BeginnerTenPullCost = 8;
public const int BeginnerTenPullCost = 10;
public const int FourStarHardPity = 10;
public const int FiveStarSoftPityStart = 66;
public const int FiveStarHardPity = 80;
public const int EntropyPerPullToken = 100;
public const int EntropyPerCommit = 100;
public const int RootCreditsToEntropy = 1;
public const int TestRootCreditsBalance = 10000;
private const double FourStarBaseChance = 0.12d;
@@ -41,11 +42,10 @@ namespace KernelPanic.Meta
{
this._random = random ?? new Random();
_currencyBalances[GachaCurrencyType.StandardPull] = 0;
_currencyBalances[GachaCurrencyType.LimitedPull] = 0;
}
public int PullTokens => GetCurrencyBalance(GachaCurrencyType.StandardPull);
public int LimitedPullTokens => GetCurrencyBalance(GachaCurrencyType.LimitedPull);
public int Commits => PullTokens;
public int RootCredits { get; private set; }
public IReadOnlyList<DistroDefinition> BannerPool => _bannerPool;
public GachaBannerState BeginnerState => _beginnerState;
@@ -62,9 +62,8 @@ namespace KernelPanic.Meta
}
data.EnsureLists();
RootCredits = Math.Max(TestRootCreditsBalance, data.rootCredits);
SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.standardPullCurrency);
SetCurrencyBalanceSilently(GachaCurrencyType.LimitedPull, data.limitedPullCurrency);
RootCredits = Math.Max(0, data.rootCredits);
SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, data.commitsBalance);
_beginnerState = data.beginnerBannerState ?? new GachaBannerState(BeginnerBannerId);
_beginnerState.bannerId = BeginnerBannerId;
_beginnerState.EnsureLists();
@@ -87,8 +86,7 @@ namespace KernelPanic.Meta
data.EnsureLists();
data.rootCredits = RootCredits;
data.standardPullCurrency = GetCurrencyBalance(GachaCurrencyType.StandardPull);
data.limitedPullCurrency = GetCurrencyBalance(GachaCurrencyType.LimitedPull);
data.commitsBalance = GetCurrencyBalance(GachaCurrencyType.StandardPull);
data.beginnerBannerState = CloneState(_beginnerState);
}
@@ -125,6 +123,11 @@ namespace KernelPanic.Meta
}
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)
{
@@ -132,20 +135,20 @@ namespace KernelPanic.Meta
return false;
}
if (amount <= 0)
if (rootCredits <= 0)
{
failureReason = "no root-credits selected";
failureReason = "no Root Credits selected";
return false;
}
if (RootCredits < amount)
if (RootCredits < rootCredits)
{
failureReason = $"need {amount} root-credits";
failureReason = $"need {rootCredits} Root Credits";
return false;
}
RootCredits -= amount;
wallet.Add(amount);
RootCredits -= rootCredits;
wallet.Add(rootCredits * RootCreditsToEntropy);
failureReason = null;
Changed?.Invoke();
return true;
@@ -187,6 +190,11 @@ namespace KernelPanic.Meta
}
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)
{
@@ -208,49 +216,73 @@ namespace KernelPanic.Meta
}
int cost = GetBeginnerPullCost(pullCount);
if (GetCurrencyBalance(GachaCurrencyType.StandardPull) < cost)
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 bool CanCoverMissingPullTokensWithEntropy(EntropyWallet wallet, int missingPullTokens)
{
return missingPullTokens <= 0 || (wallet != null && wallet.Balance >= missingPullTokens * EntropyPerPullToken);
}
public GachaPullResult PerformBeginnerPull(int pullCount)
{
return PerformBeginnerPull(pullCount, null, 0);
return PerformBeginnerPull(pullCount, null, PullPaymentSource.Commits);
}
public GachaPullResult PerformBeginnerPull(int pullCount, EntropyWallet wallet, int entropyTokenCount)
{
if (!CanPullBeginnerWithEntropy(pullCount, wallet, entropyTokenCount, out string failureReason))
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);
}
int cost = GetBeginnerPullCost(pullCount);
int tokenSpend = cost - entropyTokenCount;
if (tokenSpend > 0)
if (!PayForPull(pullCount, wallet, source, out int commitSpend, out int entropySpent, out failureReason))
{
SetCurrencyBalanceSilently(GachaCurrencyType.StandardPull, GetCurrencyBalance(GachaCurrencyType.StandardPull) - tokenSpend);
}
int entropySpent = entropyTokenCount * EntropyPerPullToken;
if (entropySpent > 0)
{
wallet.Spend(entropySpent);
return GachaPullResult.Failed(BeginnerBannerId, failureReason);
}
List<GachaReward> rewards = new();
@@ -266,14 +298,13 @@ namespace KernelPanic.Meta
}
Changed?.Invoke();
return new GachaPullResult(true, BeginnerBannerId, GachaCurrencyType.StandardPull, tokenSpend, entropySpent, rewards, null);
return new GachaPullResult(true, BeginnerBannerId, GachaCurrencyType.StandardPull, commitSpend, entropySpent, rewards, null);
}
public static string FormatCurrencyName(GachaCurrencyType currencyType)
{
return currencyType switch
{
GachaCurrencyType.LimitedPull => "Root Credits",
_ => "Commits"
};
}
@@ -335,7 +366,7 @@ namespace KernelPanic.Meta
if (!hitFourStarOrCharacter)
{
_beginnerState.pityCounter++;
return GachaReward.Equipment(3, "3-star equipment", false, false);
return GachaReward.Package(3, "3-star package", false, false);
}
_beginnerState.pityCounter = 0;
@@ -345,7 +376,7 @@ namespace KernelPanic.Meta
return GachaReward.DistroReward(distro, 4, pityTriggered, false);
}
return GachaReward.Equipment(4, "4-star equipment", pityTriggered, false);
return GachaReward.Package(4, "4-star package", pityTriggered, false);
}
private bool TryRollFiveStar(GachaBannerState state, out bool pityTriggered)
@@ -379,7 +410,7 @@ namespace KernelPanic.Meta
{
if (_bannerPool.Count == 0 || _random.NextDouble() >= DistroChanceOnFeaturedTier)
{
return GachaReward.Equipment(5, "5-star equipment", pityTriggered, false);
return GachaReward.Package(5, "5-star package", pityTriggered, false);
}
DistroDefinition distro = _bannerPool.Count == 0 ? null : _bannerPool[_random.Next(_bannerPool.Count)];
@@ -420,7 +451,7 @@ namespace KernelPanic.Meta
distro ??= _bannerPool.Count == 0 ? null : _bannerPool[Math.Min(guaranteeIndex, _bannerPool.Count - 1)];
return distro == null
? GachaReward.Equipment(4, "4-star equipment", true, true)
? GachaReward.Package(4, "4-star package", true, true)
: GachaReward.DistroReward(distro, 4, true, true, reason);
}
@@ -448,52 +479,6 @@ namespace KernelPanic.Meta
_currencyBalances[currencyType] = Math.Max(0, amount);
}
private bool CanPullBeginnerWithEntropy(int pullCount, EntropyWallet wallet, int entropyTokenCount, 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 (entropyTokenCount < 0 || entropyTokenCount > cost)
{
failureReason = "invalid entropy substitute amount";
return false;
}
int tokenSpend = cost - entropyTokenCount;
if (GetCurrencyBalance(GachaCurrencyType.StandardPull) < tokenSpend)
{
failureReason = $"need {tokenSpend} {FormatCurrencyName(GachaCurrencyType.StandardPull)}";
return false;
}
int entropySpend = entropyTokenCount * EntropyPerPullToken;
if (entropySpend > 0 && (wallet == null || wallet.Balance < entropySpend))
{
failureReason = $"need {entropySpend} entropy";
return false;
}
failureReason = null;
return true;
}
private static GachaBannerState CloneState(GachaBannerState state)
{
state ??= new GachaBannerState(BeginnerBannerId);
@@ -542,9 +527,9 @@ namespace KernelPanic.Meta
public bool Guaranteed { get; }
public string GuaranteeReason { get; }
public static GachaReward Equipment(int starRating, string displayName, bool pityTriggered, bool guaranteed)
public static GachaReward Package(int starRating, string displayName, bool pityTriggered, bool guaranteed)
{
return new GachaReward(GachaRewardType.Equipment, starRating, displayName, null, pityTriggered, guaranteed, null);
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)
+190
View File
@@ -0,0 +1,190 @@
using System;
using System.Collections.Generic;
using KernelPanic.Data;
namespace KernelPanic.Meta
{
public enum PackageLoadoutFailureReason
{
None,
WrongSlot,
NotOwned,
SlotOccupied,
NotEquipped
}
/// <summary>
/// Pure meta model for persistent package slots. Unlike CardLoadout, this is not
/// run-only: equipped package ids are saved per distro and reused in every run.
/// </summary>
public sealed class PackageLoadout
{
private readonly PlayerCollection _collection;
private readonly PackageDatabase _database;
private readonly Dictionary<string, Dictionary<PackageSlot, string>> _equippedByDistro = new(StringComparer.OrdinalIgnoreCase);
public PackageLoadout(PlayerCollection collection, PackageDatabase database)
{
_collection = collection;
_database = database;
}
public IReadOnlyDictionary<PackageSlot, string> GetEquippedPackageIds(string distroId)
{
return !string.IsNullOrWhiteSpace(distroId) && _equippedByDistro.TryGetValue(distroId, out Dictionary<PackageSlot, string> equipped)
? equipped
: Empty;
}
public PackageDefinition GetEquippedPackage(string distroId, PackageSlot slot)
{
if (string.IsNullOrWhiteSpace(distroId)
|| !_equippedByDistro.TryGetValue(distroId, out Dictionary<PackageSlot, string> equipped)
|| !equipped.TryGetValue(slot, out string packageId))
{
return null;
}
return _database == null ? null : _database.FindById(packageId);
}
public IReadOnlyList<PackageDefinition> GetEquippedPackages(string distroId)
{
List<PackageDefinition> packages = new();
AddIfEquipped(distroId, PackageSlot.Kernel, packages);
AddIfEquipped(distroId, PackageSlot.Runtime, packages);
AddIfEquipped(distroId, PackageSlot.Daemon, packages);
return packages;
}
public IReadOnlyList<PackageInstance> GetEquippedPackageInstances(string distroId)
{
List<PackageInstance> packages = new();
AddInstanceIfEquipped(distroId, PackageSlot.Kernel, packages);
AddInstanceIfEquipped(distroId, PackageSlot.Runtime, packages);
AddInstanceIfEquipped(distroId, PackageSlot.Daemon, packages);
return packages;
}
public bool IsEquipped(string packageId)
{
if (string.IsNullOrWhiteSpace(packageId))
{
return false;
}
foreach (KeyValuePair<string, Dictionary<PackageSlot, string>> distro in _equippedByDistro)
{
foreach (KeyValuePair<PackageSlot, string> slot in distro.Value)
{
if (string.Equals(slot.Value, packageId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
}
return false;
}
public bool TryEquip(string distroId, PackageSlot slot, string packageId, out PackageLoadoutFailureReason reason)
{
PackageDefinition package = _database == null ? null : _database.FindById(packageId);
if (package == null || _collection == null || !_collection.IsPackageOwned(package.Id))
{
reason = PackageLoadoutFailureReason.NotOwned;
return false;
}
if (package.Slot != slot)
{
reason = PackageLoadoutFailureReason.WrongSlot;
return false;
}
Dictionary<PackageSlot, string> equipped = EnsureDistro(distroId);
if (equipped.TryGetValue(slot, out string current) && !string.Equals(current, package.Id, StringComparison.OrdinalIgnoreCase))
{
reason = PackageLoadoutFailureReason.SlotOccupied;
return false;
}
equipped[slot] = package.Id;
reason = PackageLoadoutFailureReason.None;
return true;
}
public bool TryUnequip(string distroId, PackageSlot slot, out PackageLoadoutFailureReason reason)
{
if (string.IsNullOrWhiteSpace(distroId)
|| !_equippedByDistro.TryGetValue(distroId, out Dictionary<PackageSlot, string> equipped)
|| !equipped.Remove(slot))
{
reason = PackageLoadoutFailureReason.NotEquipped;
return false;
}
reason = PackageLoadoutFailureReason.None;
return true;
}
public void Load(string distroId, IEnumerable<PackageLoadoutSlotSaveEntry> slots)
{
if (string.IsNullOrWhiteSpace(distroId))
{
return;
}
Dictionary<PackageSlot, string> equipped = EnsureDistro(distroId);
equipped.Clear();
foreach (PackageLoadoutSlotSaveEntry slotEntry in slots ?? Array.Empty<PackageLoadoutSlotSaveEntry>())
{
PackageDefinition package = _database == null ? null : _database.FindById(slotEntry?.packageId);
if (package == null || !_collection.IsPackageOwned(package.Id) || package.Slot != slotEntry.slot)
{
continue;
}
equipped[package.Slot] = package.Id;
}
}
public void Clear()
{
_equippedByDistro.Clear();
}
private void AddIfEquipped(string distroId, PackageSlot slot, List<PackageDefinition> packages)
{
PackageDefinition package = GetEquippedPackage(distroId, slot);
if (package != null)
{
packages.Add(package);
}
}
private void AddInstanceIfEquipped(string distroId, PackageSlot slot, List<PackageInstance> packages)
{
PackageDefinition definition = GetEquippedPackage(distroId, slot);
OwnedPackageInstance owned = definition == null ? null : _collection.GetOwnedPackage(definition.Id);
if (owned != null)
{
packages.Add(owned.ToRuntimeInstance());
}
}
private Dictionary<PackageSlot, string> EnsureDistro(string distroId)
{
string key = string.IsNullOrWhiteSpace(distroId) ? string.Empty : distroId;
if (!_equippedByDistro.TryGetValue(key, out Dictionary<PackageSlot, string> equipped))
{
equipped = new Dictionary<PackageSlot, string>();
_equippedByDistro[key] = equipped;
}
return equipped;
}
private static readonly IReadOnlyDictionary<PackageSlot, string> Empty = new Dictionary<PackageSlot, string>();
}
}
+64
View File
@@ -0,0 +1,64 @@
using System;
using KernelPanic.Data;
namespace KernelPanic.Meta
{
public enum PackageScrapFailureReason
{
None,
NotOwned,
Equipped
}
public readonly struct PackageScrapResult
{
public PackageScrapResult(bool success, PackageScrapFailureReason failureReason, int cacheGranted, int bandwidthRefunded)
{
Success = success;
FailureReason = failureReason;
CacheGranted = cacheGranted;
BandwidthRefunded = bandwidthRefunded;
}
public bool Success { get; }
public PackageScrapFailureReason FailureReason { get; }
public int CacheGranted { get; }
public int BandwidthRefunded { get; }
}
public static class PackageScrapper
{
public static PackageScrapResult Scrap(PlayerCollection collection, PackageLoadout loadout, SaveData wallet, string packageId)
{
if (collection == null || wallet == null || string.IsNullOrWhiteSpace(packageId))
{
return new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0);
}
OwnedPackageInstance package = collection.GetOwnedPackage(packageId);
if (package == null)
{
return new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0);
}
if (loadout != null && loadout.IsEquipped(packageId))
{
return new PackageScrapResult(false, PackageScrapFailureReason.Equipped, 0, 0);
}
PackageDefinition definition = package.Definition;
int rarity = definition == null ? 1 : definition.Rarity;
int cache = PackageTuning.GetCacheForRarity(rarity) + PackageTuning.GetRefundedInvestedCache(package.UpgradeLevel, rarity);
int bandwidth = PackageTuning.GetRefundedInvestedBandwidth(package.UpgradeLevel, rarity);
if (!collection.RemovePackage(packageId))
{
return new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0);
}
wallet.cacheBalance += Math.Max(0, cache);
wallet.bandwidthBalance += Math.Max(0, bandwidth);
return new PackageScrapResult(true, PackageScrapFailureReason.None, cache, bandwidth);
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using System;
using UnityEngine;
namespace KernelPanic.Meta
{
public static class PackageTuning
{
public const int MaxPackageLevel = 5;
public const float RefundFraction = 1f;
public const float RarityCostMultiplier = 1f;
private static readonly int[] CachePerRarityValues = { 5, 12, 30, 80, 200 };
private static readonly int[] UpgradeCacheCostValues = { 10, 25, 50, 90, 150 };
private static readonly int[] UpgradeBandwidthCostValues = { 20, 50, 100, 180, 300 };
public static int GetCacheForRarity(int rarity)
{
return CachePerRarityValues[Mathf.Clamp(rarity, 1, CachePerRarityValues.Length) - 1];
}
public static int GetUpgradeCacheCost(int nextLevel, int rarity)
{
return ApplyRarityMultiplier(UpgradeCacheCostValues[Mathf.Clamp(nextLevel, 1, UpgradeCacheCostValues.Length) - 1], rarity);
}
public static int GetUpgradeBandwidthCost(int nextLevel, int rarity)
{
return ApplyRarityMultiplier(UpgradeBandwidthCostValues[Mathf.Clamp(nextLevel, 1, UpgradeBandwidthCostValues.Length) - 1], rarity);
}
public static int GetInvestedCache(int level, int rarity)
{
int total = 0;
for (int nextLevel = 1; nextLevel <= Mathf.Clamp(level, 0, MaxPackageLevel); nextLevel++)
{
total += GetUpgradeCacheCost(nextLevel, rarity);
}
return total;
}
public static int GetInvestedBandwidth(int level, int rarity)
{
int total = 0;
for (int nextLevel = 1; nextLevel <= Mathf.Clamp(level, 0, MaxPackageLevel); nextLevel++)
{
total += GetUpgradeBandwidthCost(nextLevel, rarity);
}
return total;
}
public static int GetRefundedInvestedCache(int level, int rarity)
{
return Mathf.RoundToInt(GetInvestedCache(level, rarity) * RefundFraction);
}
public static int GetRefundedInvestedBandwidth(int level, int rarity)
{
return Mathf.RoundToInt(GetInvestedBandwidth(level, rarity) * RefundFraction);
}
private static int ApplyRarityMultiplier(int baseCost, int rarity)
{
float multiplier = RarityCostMultiplier <= 0f ? 1f : RarityCostMultiplier;
return Math.Max(0, Mathf.RoundToInt(baseCost * multiplier));
}
}
}
+72
View File
@@ -0,0 +1,72 @@
using System;
using KernelPanic.Data;
namespace KernelPanic.Meta
{
public enum PackageUpgradeFailureReason
{
None,
NotOwned,
MaxLevel,
InsufficientCache,
InsufficientBandwidth
}
public readonly struct PackageUpgradeResult
{
public PackageUpgradeResult(bool success, PackageUpgradeFailureReason failureReason, int cacheCost, int bandwidthCost, int newLevel)
{
Success = success;
FailureReason = failureReason;
CacheCost = cacheCost;
BandwidthCost = bandwidthCost;
NewLevel = newLevel;
}
public bool Success { get; }
public PackageUpgradeFailureReason FailureReason { get; }
public int CacheCost { get; }
public int BandwidthCost { get; }
public int NewLevel { get; }
}
public static class PackageUpgrader
{
public static event Action<OwnedPackageInstance> Upgraded;
public static PackageUpgradeResult TryUpgrade(OwnedPackageInstance packageInstance, SaveData wallet)
{
if (packageInstance == null || wallet == null)
{
return new PackageUpgradeResult(false, PackageUpgradeFailureReason.NotOwned, 0, 0, 0);
}
int currentLevel = Math.Max(0, packageInstance.UpgradeLevel);
if (currentLevel >= PackageTuning.MaxPackageLevel)
{
return new PackageUpgradeResult(false, PackageUpgradeFailureReason.MaxLevel, 0, 0, currentLevel);
}
int nextLevel = currentLevel + 1;
PackageDefinition definition = packageInstance.Definition;
int rarity = definition == null ? 1 : definition.Rarity;
int cacheCost = PackageTuning.GetUpgradeCacheCost(nextLevel, rarity);
int bandwidthCost = PackageTuning.GetUpgradeBandwidthCost(nextLevel, rarity);
if (wallet.cacheBalance < cacheCost)
{
return new PackageUpgradeResult(false, PackageUpgradeFailureReason.InsufficientCache, cacheCost, bandwidthCost, currentLevel);
}
if (wallet.bandwidthBalance < bandwidthCost)
{
return new PackageUpgradeResult(false, PackageUpgradeFailureReason.InsufficientBandwidth, cacheCost, bandwidthCost, currentLevel);
}
wallet.cacheBalance -= cacheCost;
wallet.bandwidthBalance -= bandwidthCost;
packageInstance.SetUpgradeLevel(nextLevel);
Upgraded?.Invoke(packageInstance);
return new PackageUpgradeResult(true, PackageUpgradeFailureReason.None, cacheCost, bandwidthCost, nextLevel);
}
}
}
+123
View File
@@ -10,11 +10,14 @@ namespace KernelPanic.Meta
public sealed class PlayerCollection
{
private readonly List<DistroDefinition> _ownedUnits = new();
private readonly List<OwnedPackageInstance> _ownedPackages = new();
private readonly Dictionary<string, int> _versions = new(StringComparer.OrdinalIgnoreCase);
private int _featuredIndex;
// TODO: Populate owned units from SaveService during bootstrap.
public IReadOnlyList<DistroDefinition> OwnedUnits => _ownedUnits;
public IReadOnlyList<OwnedPackageInstance> OwnedPackageInstances => _ownedPackages;
public IReadOnlyList<PackageDefinition> OwnedPackages => BuildOwnedPackageDefinitions();
public DistroDefinition FeaturedUnit => _ownedUnits.Count == 0 ? null : _ownedUnits[Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1)];
public event Action Changed;
@@ -34,6 +37,35 @@ namespace KernelPanic.Meta
Add(unit, version, false);
}
public void AddPackage(PackageDefinition package)
{
AddPackage(package, 0, true);
}
public void AddPackageSilently(PackageDefinition package)
{
AddPackage(package, 0, false);
}
public void AddPackageSilently(PackageDefinition package, int upgradeLevel)
{
AddPackage(package, upgradeLevel, false);
}
private void AddPackage(PackageDefinition package, int upgradeLevel, bool notify)
{
if (package == null || IsPackageOwned(package.Id))
{
return;
}
_ownedPackages.Add(new OwnedPackageInstance(package, upgradeLevel));
if (notify)
{
Changed?.Invoke();
}
}
private void Add(DistroDefinition unit, int version, bool notify)
{
if (unit == null)
@@ -82,6 +114,60 @@ namespace KernelPanic.Meta
return count;
}
public bool IsPackageOwned(string packageId)
{
if (string.IsNullOrWhiteSpace(packageId))
{
return false;
}
for (int i = 0; i < _ownedPackages.Count; i++)
{
OwnedPackageInstance package = _ownedPackages[i];
if (package != null && string.Equals(package.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
public OwnedPackageInstance GetOwnedPackage(string packageId)
{
if (string.IsNullOrWhiteSpace(packageId))
{
return null;
}
for (int i = 0; i < _ownedPackages.Count; i++)
{
OwnedPackageInstance package = _ownedPackages[i];
if (package != null && string.Equals(package.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
{
return package;
}
}
return null;
}
public bool RemovePackage(string packageId)
{
for (int i = _ownedPackages.Count - 1; i >= 0; i--)
{
OwnedPackageInstance package = _ownedPackages[i];
if (package != null && string.Equals(package.PackageId, packageId, StringComparison.OrdinalIgnoreCase))
{
_ownedPackages.RemoveAt(i);
Changed?.Invoke();
return true;
}
}
return false;
}
public int GetVersion(string unitId)
{
if (string.IsNullOrWhiteSpace(unitId))
@@ -131,5 +217,42 @@ namespace KernelPanic.Meta
_featuredIndex = (_featuredIndex + 1) % _ownedUnits.Count;
}
private IReadOnlyList<PackageDefinition> BuildOwnedPackageDefinitions()
{
List<PackageDefinition> definitions = new();
for (int i = 0; i < _ownedPackages.Count; i++)
{
if (_ownedPackages[i]?.Definition != null)
{
definitions.Add(_ownedPackages[i].Definition);
}
}
return definitions;
}
}
public sealed class OwnedPackageInstance
{
public OwnedPackageInstance(PackageDefinition definition, int upgradeLevel)
{
Definition = definition;
UpgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, upgradeLevel));
}
public PackageDefinition Definition { get; }
public string PackageId => Definition == null ? string.Empty : Definition.Id;
public int UpgradeLevel { get; private set; }
public PackageInstance ToRuntimeInstance()
{
return new PackageInstance(Definition, UpgradeLevel);
}
public void SetUpgradeLevel(int level)
{
UpgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, level));
}
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ namespace KernelPanic.Meta
owned.merges = Math.Max(0, owned.merges) + merges;
if (GachaTuning.DupeConsolationBandwidth > 0)
{
context.SaveData.standardPullCurrency += GachaTuning.DupeConsolationBandwidth;
context.SaveData.bandwidthBalance += GachaTuning.DupeConsolationBandwidth;
}
context.Collection?.AddSilently(unit, version);
+219
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using KernelPanic.Data;
using UnityEngine;
namespace KernelPanic.Meta
@@ -60,16 +61,28 @@ namespace KernelPanic.Meta
public bool starterChosen;
public int entropyBalance;
public int rootCredits = GachaService.TestRootCreditsBalance;
public int commitsBalance;
public int bandwidthBalance;
// Legacy conflated balance. Older builds used this for both Commits and Bandwidth.
// Migration preserves it as Bandwidth and starts Commits at 0 because the source is ambiguous.
public int standardPullCurrency;
// Legacy pull-token field kept only so older saves deserialize cleanly.
public int limitedPullCurrency;
public int cacheBalance;
public int packageMerges;
// Legacy global balance kept only so older saves deserialize cleanly.
// New duplicate-pull merges live on OwnedUnitSaveEntry.merges.
public int merges;
public List<OwnedUnitSaveEntry> ownedUnits = new();
public List<OwnedPackageSaveEntry> ownedPackages = new();
public List<string> ownedPackageIds = new();
public List<PackageLoadoutSaveEntry> packageLoadouts = new();
public List<string> ownedUnitIds = new();
public List<string> bannerPoolIds = new();
public GachaBannerState beginnerBannerState = new(GachaService.BeginnerBannerId);
public LastRunLoadoutSaveEntry lastRunLoadout = new();
public string lastRunDistroId;
public List<DistroBestWaveSaveEntry> distroBestWaves = new();
public static SaveData CreateDefault()
{
@@ -79,19 +92,56 @@ namespace KernelPanic.Meta
public void EnsureLists()
{
ownedUnits ??= new List<OwnedUnitSaveEntry>();
ownedPackages ??= new List<OwnedPackageSaveEntry>();
ownedPackageIds ??= new List<string>();
packageLoadouts ??= new List<PackageLoadoutSaveEntry>();
ownedUnitIds ??= new List<string>();
bannerPoolIds ??= new List<string>();
distroBestWaves ??= new List<DistroBestWaveSaveEntry>();
beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId);
beginnerBannerState.bannerId = GachaService.BeginnerBannerId;
beginnerBannerState.EnsureLists();
lastRunLoadout ??= new LastRunLoadoutSaveEntry();
lastRunLoadout.EnsureLists();
if (string.IsNullOrWhiteSpace(lastRunDistroId) && !string.IsNullOrWhiteSpace(lastRunLoadout.distroId))
{
lastRunDistroId = lastRunLoadout.distroId;
}
MigrateLegacyConflatedCurrency();
entropyBalance = Math.Max(0, entropyBalance);
rootCredits = Math.Max(0, rootCredits);
commitsBalance = Math.Max(0, commitsBalance);
bandwidthBalance = Math.Max(0, bandwidthBalance);
merges = Math.Max(0, merges);
packageMerges = Math.Max(0, packageMerges);
cacheBalance = Math.Max(0, cacheBalance);
MigrateLegacyOwnedUnitIds();
MigrateLegacyOwnedPackageIds();
NormalizeOwnedUnits();
NormalizeOwnedPackages();
NormalizePackageLoadouts();
NormalizeDistroBestWaves();
MigrateLegacyGlobalMerges();
}
private void MigrateLegacyConflatedCurrency()
{
if (standardPullCurrency <= 0)
{
return;
}
if (bandwidthBalance <= 0)
{
bandwidthBalance = Math.Max(0, standardPullCurrency);
Debug.Log("Migrated legacy standardPullCurrency to Bandwidth. Commits defaulted to 0 because the old field conflated pull tokens and upgrade currency.");
}
standardPullCurrency = 0;
limitedPullCurrency = 0;
}
public OwnedUnitSaveEntry FindOwnedUnit(string id)
{
if (string.IsNullOrWhiteSpace(id))
@@ -176,6 +226,133 @@ namespace KernelPanic.Meta
}
}
private void NormalizeOwnedPackages()
{
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
for (int i = ownedPackages.Count - 1; i >= 0; i--)
{
OwnedPackageSaveEntry entry = ownedPackages[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.id) || !seen.Add(entry.id))
{
ownedPackages.RemoveAt(i);
continue;
}
entry.upgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, entry.upgradeLevel));
}
ownedPackageIds.Clear();
for (int i = 0; i < ownedPackages.Count; i++)
{
ownedPackageIds.Add(ownedPackages[i].id);
}
}
public int GetBestWave(string distroId)
{
if (string.IsNullOrWhiteSpace(distroId))
{
return 0;
}
EnsureLists();
for (int i = 0; i < distroBestWaves.Count; i++)
{
DistroBestWaveSaveEntry entry = distroBestWaves[i];
if (entry != null && string.Equals(entry.distroId, distroId, StringComparison.OrdinalIgnoreCase))
{
return Math.Max(0, entry.bestWave);
}
}
return 0;
}
public void RecordRunStats(string distroId, int waveReached)
{
if (string.IsNullOrWhiteSpace(distroId))
{
return;
}
EnsureLists();
lastRunDistroId = distroId;
lastRunLoadout ??= new LastRunLoadoutSaveEntry();
lastRunLoadout.distroId = distroId;
int safeWave = Math.Max(0, waveReached);
for (int i = 0; i < distroBestWaves.Count; i++)
{
DistroBestWaveSaveEntry entry = distroBestWaves[i];
if (entry != null && string.Equals(entry.distroId, distroId, StringComparison.OrdinalIgnoreCase))
{
entry.bestWave = Math.Max(entry.bestWave, safeWave);
return;
}
}
distroBestWaves.Add(new DistroBestWaveSaveEntry { distroId = distroId, bestWave = safeWave });
}
private void NormalizeDistroBestWaves()
{
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
for (int i = distroBestWaves.Count - 1; i >= 0; i--)
{
DistroBestWaveSaveEntry entry = distroBestWaves[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.distroId) || !seen.Add(entry.distroId))
{
distroBestWaves.RemoveAt(i);
continue;
}
entry.bestWave = Math.Max(0, entry.bestWave);
}
}
private void MigrateLegacyOwnedPackageIds()
{
for (int i = 0; i < ownedPackageIds.Count; i++)
{
string id = ownedPackageIds[i];
if (string.IsNullOrWhiteSpace(id) || HasOwnedPackageEntry(id))
{
continue;
}
ownedPackages.Add(new OwnedPackageSaveEntry { id = id, upgradeLevel = 0 });
}
}
private bool HasOwnedPackageEntry(string id)
{
for (int i = 0; i < ownedPackages.Count; i++)
{
OwnedPackageSaveEntry entry = ownedPackages[i];
if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private void NormalizePackageLoadouts()
{
HashSet<string> seenDistros = new(StringComparer.OrdinalIgnoreCase);
for (int i = packageLoadouts.Count - 1; i >= 0; i--)
{
PackageLoadoutSaveEntry entry = packageLoadouts[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.distroId) || !seenDistros.Add(entry.distroId))
{
packageLoadouts.RemoveAt(i);
continue;
}
entry.EnsureLists();
}
}
private void MigrateLegacyGlobalMerges()
{
if (merges <= 0 || ownedUnits.Count == 0)
@@ -223,6 +400,13 @@ namespace KernelPanic.Meta
public int merges;
}
[Serializable]
public sealed class OwnedPackageSaveEntry
{
public string id;
public int upgradeLevel;
}
[Serializable]
public sealed class LastRunLoadoutSaveEntry
{
@@ -234,4 +418,39 @@ namespace KernelPanic.Meta
cardIds ??= new List<string>();
}
}
[Serializable]
public sealed class DistroBestWaveSaveEntry
{
public string distroId;
public int bestWave;
}
[Serializable]
public sealed class PackageLoadoutSaveEntry
{
public string distroId;
public List<PackageLoadoutSlotSaveEntry> slots = new();
public void EnsureLists()
{
slots ??= new List<PackageLoadoutSlotSaveEntry>();
HashSet<PackageSlot> seen = new();
for (int i = slots.Count - 1; i >= 0; i--)
{
PackageLoadoutSlotSaveEntry slot = slots[i];
if (slot == null || string.IsNullOrWhiteSpace(slot.packageId) || !seen.Add(slot.slot))
{
slots.RemoveAt(i);
}
}
}
}
[Serializable]
public sealed class PackageLoadoutSlotSaveEntry
{
public PackageSlot slot;
public string packageId;
}
}