Implement animations, pipeline for duplicates and merging.

This commit is contained in:
2026-07-07 14:10:31 +02:00
parent 30db1e1108
commit d504e0b916
22 changed files with 1947 additions and 117 deletions
@@ -0,0 +1,62 @@
using System;
using System.Collections.Generic;
namespace KernelPanic.Meta
{
public static class DistroVersionCatalog
{
private static readonly Dictionary<string, string[]> ReleaseLabels = new(StringComparer.OrdinalIgnoreCase)
{
["ubuntu"] = new[] { "20.04", "21.04", "22.04", "23.04", "24.04" },
["fedora"] = new[] { "38", "39", "40", "41", "42" },
["mint"] = new[] { "21", "21.2", "21.3", "22", "22.1" }
};
public static string GetReleaseLabel(string unitId, int version)
{
int safeVersion = Math.Max(1, Math.Min(GachaTuning.MaxVersion, version));
if (!string.IsNullOrWhiteSpace(unitId) && ReleaseLabels.TryGetValue(unitId, out string[] labels) && safeVersion <= labels.Length)
{
return labels[safeVersion - 1];
}
return $"V{safeVersion}";
}
public static string GetEffectSummary(string unitId, int version)
{
int safeVersion = Math.Max(1, Math.Min(GachaTuning.MaxVersion, version));
return (unitId ?? string.Empty).ToLowerInvariant() switch
{
"ubuntu" => safeVersion switch
{
1 => "start of turn: apt update chooses the cheaper of top 2 draw cards",
2 => "apt update looks at top 3 instead of top 2",
3 => "ask ubuntu draws 3 instead of 2",
4 => "apt update card costs 1 less this turn",
5 => "once per combat, empty hand refills up to RAM",
_ => "--"
},
"fedora" => safeVersion switch
{
1 => "first card each turn costs 1 less and deals +50%, with crash risk",
2 => "first-card damage bonus rises to +75%",
3 => "rawhide TODO: double permanent damage growth on non-crash first-card turn",
4 => "crashes grant +1 Cycle that turn",
5 => "first two cards each turn get the discount, bonus, and independent crash rolls",
_ => "--"
},
"mint" => safeVersion switch
{
1 => "random values roll max; no crits; ignores multiplicative damage buffs",
2 => "fixed effect values are treated as +2",
3 => "timeshift snapshot TODO: stronger restore when delayed restore exists",
4 => "flat +damage can apply; multiplicative crit/mult remains ignored",
5 => "TODO: re-resolve reduced effect when enemy reduction/coercion exists",
_ => "--"
},
_ => safeVersion == 1 ? "base release" : $"TODO: V{safeVersion} release effect"
};
}
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace KernelPanic.Meta
{
/// <summary>
/// Tunable gacha progression amounts. Kept in code until designers need asset-driven economy tuning.
/// </summary>
public static class GachaTuning
{
public const int MaxVersion = 5;
public const int MergesPerDupe = 20;
public const int MergesOnCharacterBonus = 10;
public const float MergesMaxVersionOverflowMultiplier = 1.5f;
public static int DupeConsolationBandwidth => 0;
public static readonly int[] VersionCosts = { 50, 120, 250, 500 };
public static int GetVersionUpgradeCost(int targetVersion)
{
if (targetVersion <= 1 || targetVersion > MaxVersion)
{
return 0;
}
int index = targetVersion - 2;
return index >= 0 && index < VersionCosts.Length ? VersionCosts[index] : VersionCosts[VersionCosts.Length - 1];
}
}
}
+72 -1
View File
@@ -10,6 +10,7 @@ namespace KernelPanic.Meta
public sealed class PlayerCollection
{
private readonly List<DistroDefinition> _ownedUnits = new();
private readonly Dictionary<string, int> _versions = new(StringComparer.OrdinalIgnoreCase);
private int _featuredIndex;
// TODO: Populate owned units from SaveService during bootstrap.
@@ -19,16 +20,46 @@ namespace KernelPanic.Meta
public event Action Changed;
public void Add(DistroDefinition unit)
{
Add(unit, 1);
}
public void Add(DistroDefinition unit, int version)
{
Add(unit, version, true);
}
public void AddSilently(DistroDefinition unit, int version)
{
Add(unit, version, false);
}
private void Add(DistroDefinition unit, int version, bool notify)
{
if (unit == null)
{
return;
}
if (IsOwned(unit.Id))
{
SetVersion(unit.Id, version, notify);
return;
}
// Persistence is wired by the owner so this domain class stays IO-free.
_ownedUnits.Add(unit);
_versions[unit.Id] = Math.Max(1, version);
_featuredIndex = Math.Clamp(_featuredIndex, 0, _ownedUnits.Count - 1);
Changed?.Invoke();
if (notify)
{
Changed?.Invoke();
}
}
public bool IsOwned(string unitId)
{
return GetOwnedCount(unitId) > 0;
}
public int GetOwnedCount(string unitId)
@@ -51,6 +82,46 @@ namespace KernelPanic.Meta
return count;
}
public int GetVersion(string unitId)
{
if (string.IsNullOrWhiteSpace(unitId))
{
return 0;
}
return _versions.TryGetValue(unitId, out int version) ? Math.Max(1, version) : IsOwned(unitId) ? 1 : 0;
}
public void SetVersion(string unitId, int version)
{
SetVersion(unitId, version, true);
}
public void SetVersionSilently(string unitId, int version)
{
SetVersion(unitId, version, false);
}
private void SetVersion(string unitId, int version, bool notify)
{
if (string.IsNullOrWhiteSpace(unitId) || !IsOwned(unitId))
{
return;
}
int safeVersion = Math.Max(1, version);
if (_versions.TryGetValue(unitId, out int oldVersion) && oldVersion == safeVersion)
{
return;
}
_versions[unitId] = safeVersion;
if (notify)
{
Changed?.Invoke();
}
}
public void SelectNextFeatured()
{
if (_ownedUnits.Count < 2)
+225
View File
@@ -0,0 +1,225 @@
using System;
using System.Collections.Generic;
using KernelPanic.Core;
using KernelPanic.Data;
using UnityEngine;
namespace KernelPanic.Meta
{
public static class PullResolver
{
public static event Action<PullResolutionResult> PullResolved;
public static PullResolutionResult Resolve(IEnumerable<DistroDefinition> pullResults, PullResolutionContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
context.SaveData ??= SaveData.CreateDefault();
context.SaveData.EnsureLists();
List<PullResolutionOutcome> outcomes = new();
if (pullResults != null)
{
foreach (DistroDefinition unit in pullResults)
{
outcomes.Add(ResolveOne(unit, context));
}
}
PullResolutionResult result = new(outcomes);
PullResolved?.Invoke(result);
return result;
}
public static PullResolutionResult Resolve(IEnumerable<string> pullResultIds, PullResolutionContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
List<DistroDefinition> units = new();
if (pullResultIds != null)
{
foreach (string id in pullResultIds)
{
units.Add(context.DistroDatabase == null ? null : context.DistroDatabase.FindById(id));
}
}
return Resolve(units, context);
}
public static PullResolutionResult ResolveAndSave(IEnumerable<DistroDefinition> pullResults, PullResolutionContext context, SaveService saveService)
{
PullResolutionResult result = Resolve(pullResults, context);
saveService?.Save(context.SaveData);
return result;
}
public static PullResolutionResult ResolveAndSave(IEnumerable<string> pullResultIds, PullResolutionContext context, SaveService saveService)
{
PullResolutionResult result = Resolve(pullResultIds, context);
saveService?.Save(context.SaveData);
return result;
}
private static PullResolutionOutcome ResolveOne(DistroDefinition unit, PullResolutionContext context)
{
if (unit == null || string.IsNullOrWhiteSpace(unit.Id))
{
return new PullResolutionOutcome(null, PullOutcomeKind.Invalid, 0, Array.Empty<Language>());
}
OwnedUnitSaveEntry owned = context.SaveData.FindOwnedUnit(unit.Id);
bool ownedInCollection = context.Collection != null && context.Collection.IsOwned(unit.Id);
if (owned == null && !ownedInCollection)
{
IReadOnlyList<Language> before = SnapshotUnlockedLanguages(context.Collection);
context.SaveData.AddOwnedUnit(unit.Id, 1);
context.Collection?.AddSilently(unit, 1);
IReadOnlyList<Language> unlocked = NewlyUnlockedLanguages(before, context.Collection, unit);
return new PullResolutionOutcome(unit.Id, PullOutcomeKind.Granted, 0, unlocked);
}
owned ??= context.SaveData.AddOwnedUnit(unit.Id, context.Collection?.GetVersion(unit.Id) ?? 1);
int version = Mathf.Clamp(owned.version, 1, GachaTuning.MaxVersion);
owned.version = version;
bool overflow = version >= GachaTuning.MaxVersion;
int merges = CalculateDupeMerges(overflow, unit.Id, context.FocusUnitId);
context.SaveData.merges = Math.Max(0, context.SaveData.merges) + merges;
if (GachaTuning.DupeConsolationBandwidth > 0)
{
context.SaveData.standardPullCurrency += GachaTuning.DupeConsolationBandwidth;
}
context.Collection?.AddSilently(unit, version);
return new PullResolutionOutcome(unit.Id, overflow ? PullOutcomeKind.DupeOverflow : PullOutcomeKind.Dupe, merges, Array.Empty<Language>());
}
private static int CalculateDupeMerges(bool overflow, string unitId, string focusUnitId)
{
float baseAmount = GachaTuning.MergesPerDupe;
if (overflow)
{
baseAmount *= GachaTuning.MergesMaxVersionOverflowMultiplier;
}
int merges = Mathf.RoundToInt(baseAmount);
if (!string.IsNullOrWhiteSpace(focusUnitId) && string.Equals(unitId, focusUnitId, StringComparison.OrdinalIgnoreCase))
{
merges += GachaTuning.MergesOnCharacterBonus;
}
return Math.Max(1, merges);
}
private static IReadOnlyList<Language> SnapshotUnlockedLanguages(PlayerCollection collection)
{
List<Language> unlocked = new();
if (collection == null)
{
return unlocked;
}
IReadOnlyList<LanguageCatalogEntry> languages = LanguageCatalog.All;
for (int i = 0; i < languages.Count; i++)
{
if (LanguageUnlock.IsUnlocked(languages[i].Language, collection))
{
unlocked.Add(languages[i].Language);
}
}
return unlocked;
}
private static IReadOnlyList<Language> NewlyUnlockedLanguages(IReadOnlyList<Language> before, PlayerCollection collection, DistroDefinition unit)
{
List<Language> unlocked = new();
if (collection == null || unit == null)
{
return unlocked;
}
AddNewlyUnlockedLanguage(unit.PrimaryLanguage, before, collection, unlocked);
AddNewlyUnlockedLanguage(unit.SecondaryLanguage, before, collection, unlocked);
return unlocked;
}
private static void AddNewlyUnlockedLanguage(Language language, IReadOnlyList<Language> before, PlayerCollection collection, List<Language> unlocked)
{
if (!ContainsLanguage(unlocked, language) && !ContainsLanguage(before, language) && LanguageUnlock.IsUnlocked(language, collection))
{
unlocked.Add(language);
}
}
private static bool ContainsLanguage(IReadOnlyList<Language> languages, Language language)
{
for (int i = 0; i < languages.Count; i++)
{
if (languages[i] == language)
{
return true;
}
}
return false;
}
}
public sealed class PullResolutionContext
{
public PullResolutionContext(SaveData saveData, PlayerCollection collection, DistroDatabase distroDatabase = null, string focusUnitId = null)
{
SaveData = saveData;
Collection = collection;
DistroDatabase = distroDatabase;
FocusUnitId = focusUnitId;
}
public SaveData SaveData { get; set; }
public PlayerCollection Collection { get; }
public DistroDatabase DistroDatabase { get; }
public string FocusUnitId { get; }
}
public sealed class PullResolutionResult
{
public PullResolutionResult(IReadOnlyList<PullResolutionOutcome> outcomes)
{
Outcomes = outcomes ?? Array.Empty<PullResolutionOutcome>();
}
public IReadOnlyList<PullResolutionOutcome> Outcomes { get; }
}
public readonly struct PullResolutionOutcome
{
public PullResolutionOutcome(string unitId, PullOutcomeKind kind, int mergesAwarded, IReadOnlyList<Language> languagesNewlyUnlocked)
{
UnitId = unitId;
Kind = kind;
MergesAwarded = mergesAwarded;
LanguagesNewlyUnlocked = languagesNewlyUnlocked ?? Array.Empty<Language>();
}
public string UnitId { get; }
public PullOutcomeKind Kind { get; }
public int MergesAwarded { get; }
public IReadOnlyList<Language> LanguagesNewlyUnlocked { get; }
}
public enum PullOutcomeKind
{
Invalid,
Granted,
Dupe,
DupeOverflow
}
}
+110
View File
@@ -62,6 +62,8 @@ namespace KernelPanic.Meta
public int rootCredits = GachaService.TestRootCreditsBalance;
public int standardPullCurrency;
public int limitedPullCurrency;
public int merges;
public List<OwnedUnitSaveEntry> ownedUnits = new();
public List<string> ownedUnitIds = new();
public List<string> bannerPoolIds = new();
public GachaBannerState beginnerBannerState = new(GachaService.BeginnerBannerId);
@@ -74,6 +76,7 @@ namespace KernelPanic.Meta
public void EnsureLists()
{
ownedUnits ??= new List<OwnedUnitSaveEntry>();
ownedUnitIds ??= new List<string>();
bannerPoolIds ??= new List<string>();
beginnerBannerState ??= new GachaBannerState(GachaService.BeginnerBannerId);
@@ -81,7 +84,114 @@ namespace KernelPanic.Meta
beginnerBannerState.EnsureLists();
lastRunLoadout ??= new LastRunLoadoutSaveEntry();
lastRunLoadout.EnsureLists();
merges = Math.Max(0, merges);
MigrateLegacyOwnedUnitIds();
NormalizeOwnedUnits();
}
public OwnedUnitSaveEntry FindOwnedUnit(string id)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
EnsureLists();
for (int i = 0; i < ownedUnits.Count; i++)
{
OwnedUnitSaveEntry entry = ownedUnits[i];
if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase))
{
return entry;
}
}
return null;
}
public bool IsUnitOwned(string id)
{
return FindOwnedUnit(id) != null;
}
public OwnedUnitSaveEntry AddOwnedUnit(string id, int version)
{
if (string.IsNullOrWhiteSpace(id))
{
return null;
}
EnsureLists();
OwnedUnitSaveEntry existing = FindOwnedUnit(id);
if (existing != null)
{
existing.version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, existing.version));
return existing;
}
OwnedUnitSaveEntry entry = new()
{
id = id,
version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, version))
};
ownedUnits.Add(entry);
return entry;
}
private void MigrateLegacyOwnedUnitIds()
{
for (int i = 0; i < ownedUnitIds.Count; i++)
{
string id = ownedUnitIds[i];
if (string.IsNullOrWhiteSpace(id) || HasOwnedUnitEntry(id))
{
continue;
}
ownedUnits.Add(new OwnedUnitSaveEntry
{
id = id,
version = 1
});
}
}
private void NormalizeOwnedUnits()
{
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
for (int i = ownedUnits.Count - 1; i >= 0; i--)
{
OwnedUnitSaveEntry entry = ownedUnits[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.id) || !seen.Add(entry.id))
{
ownedUnits.RemoveAt(i);
continue;
}
entry.version = Math.Max(1, Math.Min(GachaTuning.MaxVersion, entry.version));
}
}
private bool HasOwnedUnitEntry(string id)
{
for (int i = 0; i < ownedUnits.Count; i++)
{
OwnedUnitSaveEntry entry = ownedUnits[i];
if (entry != null && string.Equals(entry.id, id, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
[Serializable]
public sealed class OwnedUnitSaveEntry
{
public string id;
public int version = 1;
}
[Serializable]
+105
View File
@@ -0,0 +1,105 @@
using System;
using UnityEngine;
namespace KernelPanic.Meta
{
public static class VersionUpgrader
{
public static event Action<VersionUpgradeResult> UnitUpgraded;
public static bool TryUpgrade(string unitId, SaveData state, PlayerCollection collection, out VersionUpgradeResult result)
{
result = TryUpgrade(unitId, state, collection);
return result.Success;
}
public static VersionUpgradeResult TryUpgradeAndSave(string unitId, SaveData state, PlayerCollection collection, SaveService saveService)
{
VersionUpgradeResult result = TryUpgrade(unitId, state, collection);
if (result.Success)
{
saveService?.Save(state);
}
return result;
}
public static VersionUpgradeResult TryUpgrade(string unitId, SaveData state, PlayerCollection collection)
{
state ??= SaveData.CreateDefault();
state.EnsureLists();
OwnedUnitSaveEntry entry = state.FindOwnedUnit(unitId);
if (entry == null && collection != null && collection.IsOwned(unitId))
{
entry = state.AddOwnedUnit(unitId, collection.GetVersion(unitId));
}
if (entry == null)
{
return VersionUpgradeResult.Failed(unitId, VersionUpgradeFailureReason.NotOwned);
}
int currentVersion = Mathf.Clamp(entry.version, 1, GachaTuning.MaxVersion);
if (currentVersion >= GachaTuning.MaxVersion)
{
return VersionUpgradeResult.Failed(unitId, VersionUpgradeFailureReason.MaxVersion, currentVersion, currentVersion, 0);
}
int targetVersion = currentVersion + 1;
int cost = GachaTuning.GetVersionUpgradeCost(targetVersion);
if (state.merges < cost)
{
return VersionUpgradeResult.Failed(unitId, VersionUpgradeFailureReason.InsufficientMerges, currentVersion, targetVersion, cost);
}
state.merges -= cost;
entry.version = targetVersion;
collection?.SetVersionSilently(unitId, targetVersion);
VersionUpgradeResult result = VersionUpgradeResult.Upgraded(unitId, currentVersion, targetVersion, cost, state.merges);
UnitUpgraded?.Invoke(result);
return result;
}
}
public readonly struct VersionUpgradeResult
{
private VersionUpgradeResult(string unitId, bool success, VersionUpgradeFailureReason failureReason, int fromVersion, int toVersion, int cost, int mergesRemaining)
{
UnitId = unitId;
Success = success;
FailureReason = failureReason;
FromVersion = fromVersion;
ToVersion = toVersion;
Cost = cost;
MergesRemaining = mergesRemaining;
}
public string UnitId { get; }
public bool Success { get; }
public VersionUpgradeFailureReason FailureReason { get; }
public int FromVersion { get; }
public int ToVersion { get; }
public int Cost { get; }
public int MergesRemaining { get; }
public static VersionUpgradeResult Upgraded(string unitId, int fromVersion, int toVersion, int cost, int mergesRemaining)
{
return new VersionUpgradeResult(unitId, true, VersionUpgradeFailureReason.None, fromVersion, toVersion, cost, mergesRemaining);
}
public static VersionUpgradeResult Failed(string unitId, VersionUpgradeFailureReason reason, int fromVersion = 0, int toVersion = 0, int cost = 0)
{
return new VersionUpgradeResult(unitId, false, reason, fromVersion, toVersion, cost, 0);
}
}
public enum VersionUpgradeFailureReason
{
None,
NotOwned,
MaxVersion,
InsufficientMerges
}
}