Merge pull request #4 from Fretuks/claude/packages-inventory-auto-scrap-duawau

Add dedicated packages collection screen with inventory management
This commit is contained in:
2026-07-09 11:14:24 +02:00
committed by GitHub
6 changed files with 454 additions and 183 deletions
+6 -2
View File
@@ -127,6 +127,9 @@ namespace KernelPanic.Meta
public string DisplayName { get; }
public GachaRewardType RewardType { get; }
public PullOutcomeKind OutcomeKind { get; }
// Holds distro dupe merges, or (when RewardType is Package) the Cache a duplicate package
// pull was auto-scrapped for. See StatusText for which unit applies.
public int MergesAwarded { get; }
public IReadOnlyList<Language> LanguagesUnlocked { get; }
public bool Guaranteed { get; }
@@ -139,11 +142,12 @@ namespace KernelPanic.Meta
{
get
{
string dupeUnit = RewardType == GachaRewardType.Package ? "cache" : "merges";
return OutcomeKind switch
{
PullOutcomeKind.Granted => "NEW",
PullOutcomeKind.Dupe => $"→ {MergesAwarded} merges",
PullOutcomeKind.DupeOverflow => $"→ {MergesAwarded} merges",
PullOutcomeKind.Dupe => $"→ {MergesAwarded} {dupeUnit}",
PullOutcomeKind.DupeOverflow => $"→ {MergesAwarded} {dupeUnit}",
_ => Guaranteed ? "guaranteed" : PityTriggered ? "pity" : string.Empty
};
}
+43 -4
View File
@@ -69,6 +69,7 @@ namespace KernelPanic.Meta
// Legacy pull-token field kept only so older saves deserialize cleanly.
public int limitedPullCurrency;
public int cacheBalance;
// Legacy per-pull package-dupe pool. Package dupes now auto-scrap directly to Cache.
public int packageMerges;
// Legacy global balance kept only so older saves deserialize cleanly.
// New duplicate-pull merges live on OwnedUnitSaveEntry.merges.
@@ -84,6 +85,13 @@ namespace KernelPanic.Meta
public string lastRunDistroId;
public List<DistroBestWaveSaveEntry> distroBestWaves = new();
// Populated by NormalizeOwnedPackages when a save contains more than one instance of the
// same package id (packages are unique-per-type). Not serialized: the owner (bootstrap code
// with access to PackageDatabase) consumes this once per load to grant Cache for the extras,
// then clears it. See MainMenuController.LoadMetaState.
[NonSerialized]
public List<CollapsedPackageDuplicate> collapsedPackageDuplicates = new();
public static SaveData CreateDefault()
{
return new SaveData();
@@ -228,17 +236,36 @@ namespace KernelPanic.Meta
private void NormalizeOwnedPackages()
{
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
for (int i = ownedPackages.Count - 1; i >= 0; i--)
collapsedPackageDuplicates.Clear();
Dictionary<string, OwnedPackageSaveEntry> highestById = new(StringComparer.OrdinalIgnoreCase);
Dictionary<string, int> extraCountById = new(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < ownedPackages.Count; i++)
{
OwnedPackageSaveEntry entry = ownedPackages[i];
if (entry == null || string.IsNullOrWhiteSpace(entry.id) || !seen.Add(entry.id))
if (entry == null || string.IsNullOrWhiteSpace(entry.id))
{
ownedPackages.RemoveAt(i);
continue;
}
entry.upgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, entry.upgradeLevel));
if (highestById.TryGetValue(entry.id, out OwnedPackageSaveEntry existing))
{
extraCountById[entry.id] = extraCountById.TryGetValue(entry.id, out int count) ? count + 1 : 1;
if (entry.upgradeLevel > existing.upgradeLevel)
{
highestById[entry.id] = entry;
}
}
else
{
highestById[entry.id] = entry;
}
}
ownedPackages = new List<OwnedPackageSaveEntry>(highestById.Values);
foreach (KeyValuePair<string, int> extra in extraCountById)
{
collapsedPackageDuplicates.Add(new CollapsedPackageDuplicate(extra.Key, extra.Value));
}
ownedPackageIds.Clear();
@@ -407,6 +434,18 @@ namespace KernelPanic.Meta
public int upgradeLevel;
}
public sealed class CollapsedPackageDuplicate
{
public CollapsedPackageDuplicate(string id, int extraCount)
{
Id = id;
ExtraCount = extraCount;
}
public string Id { get; }
public int ExtraCount { get; }
}
[Serializable]
public sealed class LastRunLoadoutSaveEntry
{
+305 -141
View File
@@ -45,6 +45,9 @@ namespace KernelPanic.UI
private int _selectedCardIndex;
private string _emptyCardMessage = "no cards installed";
private string _pendingScrapPackageId;
private IReadOnlyList<OwnedPackageInstance> _packageInventory = Array.Empty<OwnedPackageInstance>();
private int _selectedPackageInventoryIndex;
private PackageSlot? _packageInventoryFilter;
public event Action ViewChanged;
@@ -98,6 +101,16 @@ namespace KernelPanic.UI
ViewChanged?.Invoke();
}
public void ShowPackages()
{
_mode = CollectionMode.Packages;
_pendingScrapPackageId = null;
CurrentTitle = "$ ls ~/packages";
CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [click] upgrade/scrap";
RenderPackages();
ViewChanged?.Invoke();
}
public bool BackFromSubview()
{
if (_mode == CollectionMode.UnitDetail)
@@ -147,6 +160,12 @@ namespace KernelPanic.UI
return;
}
if (_mode == CollectionMode.Packages)
{
SelectPackageInventory(_selectedPackageInventoryIndex + delta);
return;
}
if (_mode == CollectionMode.UnitDetail)
{
return;
@@ -309,6 +328,268 @@ namespace KernelPanic.UI
SelectLanguage(_selectedLanguageIndex);
}
private void RenderPackages()
{
if (_list == null || _detail == null)
{
return;
}
_rows.Clear();
_list.Clear();
_detail.Clear();
_list.RemoveFromClassList("hidden");
_detailScroll?.RemoveFromClassList("collection-detail-full");
if (_detailScroll != null)
{
_detailScroll.verticalScrollerVisibility = ScrollerVisibility.Auto;
}
SaveData saveData = _getSaveData?.Invoke();
int cache = Math.Max(0, saveData?.cacheBalance ?? 0);
int bandwidth = Math.Max(0, saveData?.bandwidthBalance ?? 0);
VisualElement filterBar = new();
filterBar.AddToClassList("collection-actions");
AddPackageFilterButton(filterBar, null, "all");
AddPackageFilterButton(filterBar, PackageSlot.Kernel, "kernel");
AddPackageFilterButton(filterBar, PackageSlot.Runtime, "runtime");
AddPackageFilterButton(filterBar, PackageSlot.Daemon, "daemon");
_list.Add(filterBar);
_list.Add(BuildDetailLine("wallet", $"Cache={cache} Bandwidth={bandwidth}"));
List<OwnedPackageInstance> packages = BuildSortedOwnedPackages(_packageInventoryFilter);
_packageInventory = packages;
if (packages.Count == 0)
{
_detail.Add(new Label("no packages owned") { name = "CollectionEmptyTitle" });
_detail.Add(new Label("pull packages via gacha, or check a different slot filter") { name = "CollectionEmptyHint" });
return;
}
_selectedPackageInventoryIndex = Mathf.Clamp(_selectedPackageInventoryIndex, 0, packages.Count - 1);
PackageSlot? lastSlot = null;
for (int i = 0; i < packages.Count; i++)
{
int index = i;
OwnedPackageInstance owned = packages[i];
if (!_packageInventoryFilter.HasValue && owned.Definition.Slot != lastSlot)
{
lastSlot = owned.Definition.Slot;
Label header = new(lastSlot.Value.ToString().ToUpperInvariant());
header.AddToClassList("detail-section-title");
_list.Add(header);
}
VisualElement row = BuildPackageInventoryRow(owned, cache, bandwidth);
row.RegisterCallback<PointerEnterEvent>(_ => SelectPackageInventory(index));
row.RegisterCallback<ClickEvent>(_ => SelectPackageInventory(index));
_list.Add(row);
_rows.Add(row);
}
SelectPackageInventory(_selectedPackageInventoryIndex);
}
private void AddPackageFilterButton(VisualElement target, PackageSlot? slot, string label)
{
Button button = new(() =>
{
_packageInventoryFilter = slot;
_selectedPackageInventoryIndex = 0;
RenderPackages();
ViewChanged?.Invoke();
})
{
text = label,
focusable = false
};
button.AddToClassList("collection-tab");
button.EnableInClassList("selected", _packageInventoryFilter == slot);
target.Add(button);
}
private List<OwnedPackageInstance> BuildSortedOwnedPackages(PackageSlot? filter)
{
List<OwnedPackageInstance> packages = new();
if (_playerCollection == null)
{
return packages;
}
for (int i = 0; i < _playerCollection.OwnedPackageInstances.Count; i++)
{
OwnedPackageInstance owned = _playerCollection.OwnedPackageInstances[i];
if (owned?.Definition == null || (filter.HasValue && owned.Definition.Slot != filter.Value))
{
continue;
}
packages.Add(owned);
}
packages.Sort((a, b) =>
{
int slotCompare = ((int)a.Definition.Slot).CompareTo((int)b.Definition.Slot);
if (slotCompare != 0)
{
return slotCompare;
}
int rarityCompare = b.Definition.Rarity.CompareTo(a.Definition.Rarity);
return rarityCompare != 0 ? rarityCompare : string.Compare(PackageName(a.Definition), PackageName(b.Definition), StringComparison.OrdinalIgnoreCase);
});
return packages;
}
private VisualElement BuildPackageInventoryRow(OwnedPackageInstance owned, int cache, int bandwidth)
{
PackageDefinition package = owned.Definition;
VisualElement row = new();
row.AddToClassList("collection-row");
row.AddToClassList("package-slot-row");
Label name = new(PackageName(package));
name.AddToClassList("package-slot-name");
name.style.color = new StyleColor(RarityPresentation.ForStars(package.Rarity).Color);
row.Add(name);
Label slot = new(package.Slot.ToString().ToUpperInvariant());
slot.AddToClassList("package-slot-label");
row.Add(slot);
RarityStyle rarity = RarityPresentation.ForStars(package.Rarity);
Label rarityLabel = new(rarity.Badge);
rarityLabel.AddToClassList("package-slot-rarity");
rarityLabel.AddToClassList(rarity.ClassName);
row.Add(rarityLabel);
Label level = new($"Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}");
level.AddToClassList("package-slot-level");
row.Add(level);
Label effect = new(OneLineEffect(package, owned.UpgradeLevel, null));
effect.AddToClassList("package-slot-effect");
row.Add(effect);
string equippedOn = FindEquippedDistroName(package.Id);
if (equippedOn != null)
{
Label tag = new($"@ {equippedOn}");
tag.AddToClassList("package-notice");
row.Add(tag);
}
if (CanAffordNextUpgrade(owned, cache, bandwidth))
{
Label hint = new("upgrade");
hint.AddToClassList("unit-overview-upgrade");
row.Add(hint);
}
return row;
}
private static bool CanAffordNextUpgrade(OwnedPackageInstance owned, int cache, int bandwidth)
{
if (owned.UpgradeLevel >= PackageTuning.MaxPackageLevel)
{
return false;
}
int nextLevel = owned.UpgradeLevel + 1;
int rarity = owned.Definition.Rarity;
return cache >= PackageTuning.GetUpgradeCacheCost(nextLevel, rarity) && bandwidth >= PackageTuning.GetUpgradeBandwidthCost(nextLevel, rarity);
}
private string FindEquippedDistroName(string packageId)
{
if (_packageLoadout == null || string.IsNullOrWhiteSpace(packageId))
{
return null;
}
for (int i = 0; i < _units.Count; i++)
{
DistroDefinition unit = _units[i];
if (unit == null)
{
continue;
}
foreach (KeyValuePair<PackageSlot, string> equipped in _packageLoadout.GetEquippedPackageIds(unit.Id))
{
if (string.Equals(equipped.Value, packageId, StringComparison.OrdinalIgnoreCase))
{
return DistroPresentation.DisplayName(unit);
}
}
}
return null;
}
private void SelectPackageInventory(int index)
{
if (_packageInventory.Count == 0)
{
return;
}
_selectedPackageInventoryIndex = Mathf.Clamp(index, 0, _packageInventory.Count - 1);
ApplySelection(_selectedPackageInventoryIndex);
RenderPackageInventoryDetail(_packageInventory[_selectedPackageInventoryIndex]);
}
private void RenderPackageInventoryDetail(OwnedPackageInstance owned)
{
_detail.Clear();
if (owned?.Definition == null)
{
_detail.Add(new Label("no packages owned") { name = "CollectionEmptyTitle" });
return;
}
PackageDefinition package = owned.Definition;
SaveData saveData = _getSaveData?.Invoke();
int cache = Math.Max(0, saveData?.cacheBalance ?? 0);
int bandwidth = Math.Max(0, saveData?.bandwidthBalance ?? 0);
Label name = new(PackageName(package));
name.AddToClassList("collection-detail-name");
name.style.color = new StyleColor(RarityPresentation.ForStars(package.Rarity).Color);
_detail.Add(name);
_detail.Add(BuildDetailLine("wallet", $"Cache={cache} Bandwidth={bandwidth}"));
_detail.Add(BuildDetailLine("slot", package.Slot.ToString()));
_detail.Add(BuildDetailLine("rarity", RarityPresentation.ForStars(package.Rarity).Stars));
_detail.Add(BuildDetailLine("level", $"Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}"));
_detail.Add(BuildDetailLine("equipped", FindEquippedDistroName(package.Id) ?? "not equipped"));
_detail.Add(BuildDetailLine("current", FormatPackageEffect(package, owned.UpgradeLevel, null)));
int nextLevel = owned.UpgradeLevel + 1;
bool maxLevel = owned.UpgradeLevel >= PackageTuning.MaxPackageLevel;
int upgradeCacheCost = maxLevel ? 0 : PackageTuning.GetUpgradeCacheCost(nextLevel, package.Rarity);
int upgradeBandwidthCost = maxLevel ? 0 : PackageTuning.GetUpgradeBandwidthCost(nextLevel, package.Rarity);
bool canUpgrade = !maxLevel && cache >= upgradeCacheCost && bandwidth >= upgradeBandwidthCost && _tryUpgradePackage != null;
string upgradeState = maxLevel ? "max level" : cache < upgradeCacheCost ? "insufficient cache" : bandwidth < upgradeBandwidthCost ? "insufficient bandwidth" : "ready";
string nextPreview = maxLevel
? "max level reached"
: $"{FormatPackageEffect(package, owned.UpgradeLevel, null)} -> {FormatPackageEffect(package, nextLevel, null)}";
_detail.Add(BuildDetailLine("next", nextPreview));
_detail.Add(BuildCompactUpgradeCommand($"> make upgrade (Cache {upgradeCacheCost} / Bandwidth {upgradeBandwidthCost})", upgradeState, canUpgrade, () => UpgradePackage(owned)));
bool equipped = _packageLoadout != null && _packageLoadout.IsEquipped(package.Id);
int scrapCache = PackageTuning.GetCacheForRarity(package.Rarity) + PackageTuning.GetRefundedInvestedCache(owned.UpgradeLevel, package.Rarity);
int scrapBandwidth = PackageTuning.GetRefundedInvestedBandwidth(owned.UpgradeLevel, package.Rarity);
bool confirming = string.Equals(_pendingScrapPackageId, package.Id, StringComparison.OrdinalIgnoreCase);
string scrapCommand = confirming ? $"> rm --purge --yes (+{scrapCache} cache/+{scrapBandwidth} bandwidth)" : $"> rm --purge (+{scrapCache} cache)";
string scrapState = equipped ? "equipped: unequip first" : confirming ? "confirm permanent scrap" : "requires confirm";
_detail.Add(BuildCompactUpgradeCommand(scrapCommand, scrapState, !equipped && _tryScrapPackage != null, () => ScrapPackage(owned)));
}
private VisualElement BuildUnitOverviewRow(DistroDefinition unit)
{
VisualElement row = new();
@@ -814,82 +1095,6 @@ namespace KernelPanic.UI
}
}
private void AddPackageLoadoutPanel(VisualElement target, DistroDefinition unit)
{
if (unit == null || _packageLoadout == null || _playerCollection == null)
{
return;
}
Label title = new("meta packages (persistent)");
title.AddToClassList("detail-section-title");
target.Add(title);
target.Add(BuildDetailLine("scope", "package slots persist across every run; card loadout is run-only"));
AddPackageSlot(target, unit, PackageSlot.Kernel);
AddPackageSlot(target, unit, PackageSlot.Runtime);
AddPackageSlot(target, unit, PackageSlot.Daemon);
}
private void AddPackageSlot(VisualElement target, DistroDefinition unit, PackageSlot slot)
{
VisualElement slotBlock = new();
slotBlock.AddToClassList("package-slot-block");
PackageDefinition equipped = _packageLoadout.GetEquippedPackage(unit.Id, slot);
string equippedName = equipped == null ? "empty" : PackageName(equipped);
slotBlock.Add(BuildDetailLine(slot.ToString().ToLowerInvariant(), equippedName));
if (equipped != null)
{
Label effect = new(equipped.Description);
effect.AddToClassList("package-description");
slotBlock.Add(effect);
}
for (int i = 0; i < _playerCollection.OwnedPackages.Count; i++)
{
PackageDefinition package = _playerCollection.OwnedPackages[i];
if (package == null || package.Slot != slot)
{
continue;
}
bool isEquipped = equipped != null && string.Equals(equipped.Id, package.Id, StringComparison.OrdinalIgnoreCase);
VisualElement row = new();
row.AddToClassList("package-row");
row.EnableInClassList("equipped", isEquipped);
row.EnableInClassList($"rarity-{package.Rarity}", true);
row.RegisterCallback<ClickEvent>(_ => TogglePackage(unit, slot, package, isEquipped));
VisualElement summary = new();
summary.AddToClassList("package-summary");
Label marker = new(isEquipped ? "[x]" : "[ ]");
marker.AddToClassList("package-marker");
Label name = new(PackageName(package));
name.AddToClassList("package-name");
Label meta = new($"{package.Rarity}* / {package.Slot}");
meta.AddToClassList("package-meta");
summary.Add(marker);
summary.Add(name);
summary.Add(meta);
row.Add(summary);
Label description = new(package.Description);
description.AddToClassList("package-description");
row.Add(description);
if (package.IsIntendedFor(unit.Id))
{
Label intended = new("on-distro");
intended.AddToClassList("package-notice");
row.Add(intended);
}
slotBlock.Add(row);
}
target.Add(slotBlock);
}
private void TogglePackage(DistroDefinition unit, PackageSlot slot, PackageDefinition package, bool isEquipped)
{
if (unit == null || package == null || _packageLoadout == null)
@@ -989,72 +1194,11 @@ namespace KernelPanic.UI
}
}
private void AddPackageInventoryPanel(VisualElement target)
{
if (_playerCollection == null)
{
return;
}
SaveData saveData = _getSaveData?.Invoke();
int cache = Math.Max(0, saveData?.cacheBalance ?? 0);
int bandwidth = Math.Max(0, saveData?.bandwidthBalance ?? 0);
Label title = new("package inventory");
title.AddToClassList("detail-section-title");
target.Add(title);
target.Add(BuildDetailLine("wallet", $"Cache={cache} Bandwidth={bandwidth}"));
target.Add(BuildDetailLine("scrap", "permanent; equipped packages are protected, unequip first"));
for (int i = 0; i < _playerCollection.OwnedPackageInstances.Count; i++)
{
OwnedPackageInstance owned = _playerCollection.OwnedPackageInstances[i];
PackageDefinition package = owned?.Definition;
if (package == null)
{
continue;
}
VisualElement row = new();
row.AddToClassList("package-row");
row.EnableInClassList($"rarity-{package.Rarity}", true);
VisualElement summary = new();
summary.AddToClassList("package-summary");
Label name = new(PackageName(package));
name.AddToClassList("package-name");
Label meta = new($"{package.Rarity}* / {package.Slot} / Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}");
meta.AddToClassList("package-meta");
summary.Add(name);
summary.Add(meta);
row.Add(summary);
Label description = new(FormatPackageEffect(package, owned.UpgradeLevel, null));
description.AddToClassList("package-description");
row.Add(description);
int nextLevel = owned.UpgradeLevel + 1;
bool maxLevel = owned.UpgradeLevel >= PackageTuning.MaxPackageLevel;
int cacheCost = maxLevel ? 0 : PackageTuning.GetUpgradeCacheCost(nextLevel, package.Rarity);
int bandwidthCost = maxLevel ? 0 : PackageTuning.GetUpgradeBandwidthCost(nextLevel, package.Rarity);
bool canUpgrade = !maxLevel && cache >= cacheCost && bandwidth >= bandwidthCost && _tryUpgradePackage != null;
string upgradeState = maxLevel ? "max level" : cache < cacheCost ? "insufficient cache" : bandwidth < bandwidthCost ? "insufficient bandwidth" : "ready";
row.Add(BuildCompactUpgradeCommand($"> make upgrade ({cacheCost} cache/{bandwidthCost} bandwidth)", upgradeState, canUpgrade, () => UpgradePackage(owned)));
bool equipped = _packageLoadout != null && _packageLoadout.IsEquipped(package.Id);
int scrapCache = PackageTuning.GetCacheForRarity(package.Rarity) + PackageTuning.GetRefundedInvestedCache(owned.UpgradeLevel, package.Rarity);
int scrapBandwidth = PackageTuning.GetRefundedInvestedBandwidth(owned.UpgradeLevel, package.Rarity);
bool confirming = string.Equals(_pendingScrapPackageId, package.Id, StringComparison.OrdinalIgnoreCase);
string scrapCommand = confirming ? $"> rm --purge --yes (+{scrapCache} cache/+{scrapBandwidth} bandwidth)" : $"> rm --purge (+{scrapCache} cache)";
string scrapState = equipped ? "equipped: unequip first" : confirming ? "confirm permanent scrap" : "requires confirm";
row.Add(BuildCompactUpgradeCommand(scrapCommand, scrapState, !equipped && _tryScrapPackage != null, () => ScrapPackage(owned)));
target.Add(row);
}
}
private void UpgradePackage(OwnedPackageInstance package)
{
bool inventoryMode = _mode == CollectionMode.Packages;
PackageUpgradeResult result = _tryUpgradePackage == null ? new PackageUpgradeResult(false, PackageUpgradeFailureReason.NotOwned, 0, 0, 0) : _tryUpgradePackage(package);
RenderUnitDetail(_units[_selectedUnitIndex]);
RefreshAfterPackageAction(inventoryMode);
if (!result.Success)
{
_detail.Add(BuildDetailLine("package upgrade", FormatPackageUpgradeFailure(result.FailureReason)));
@@ -1068,22 +1212,41 @@ namespace KernelPanic.UI
return;
}
bool inventoryMode = _mode == CollectionMode.Packages;
if (!string.Equals(_pendingScrapPackageId, package.PackageId, StringComparison.OrdinalIgnoreCase))
{
_pendingScrapPackageId = package.PackageId;
RenderUnitDetail(_units[_selectedUnitIndex]);
RefreshAfterPackageAction(inventoryMode);
return;
}
PackageScrapResult result = _tryScrapPackage == null ? new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0) : _tryScrapPackage(package);
_pendingScrapPackageId = null;
RenderUnitDetail(_units[_selectedUnitIndex]);
RefreshAfterPackageAction(inventoryMode);
if (!result.Success)
{
_detail.Add(BuildDetailLine("package scrap", FormatPackageScrapFailure(result.FailureReason)));
}
}
// Wired upgrade/scrap callbacks (MainMenuController) may call RefreshUnits() internally,
// which forces CollectionMode.Units and re-renders. Restore the caller's actual view so
// the Packages inventory screen (and its keyboard navigation) don't get bumped to Units.
private void RefreshAfterPackageAction(bool inventoryMode)
{
if (inventoryMode)
{
_mode = CollectionMode.Packages;
RenderPackages();
return;
}
if (_units.Count > 0)
{
RenderUnitDetail(_units[_selectedUnitIndex]);
}
}
private void RenderLanguageDetail(LanguageCatalogEntry language)
{
_detail.Clear();
@@ -1579,6 +1742,7 @@ namespace KernelPanic.UI
{
Units,
Languages,
Packages,
UnitDetail,
CardSubview
}
+33 -20
View File
@@ -297,20 +297,12 @@ namespace KernelPanic.UI
string displayName = reward.DisplayName;
if (reward.RewardType == GachaRewardType.Package)
{
PackageDefinition package = GrantRandomPackageByRarity(pending.PackageDatabase, collection, saveData, reward.StarRating, out bool duplicatePackage);
PackageDefinition package = GrantRandomPackageByRarity(pending.PackageDatabase, collection, saveData, reward.StarRating, out bool duplicatePackage, out int cacheAwarded);
if (package != null)
{
displayName = string.IsNullOrWhiteSpace(package.DisplayName) ? package.Id : package.DisplayName;
if (duplicatePackage)
{
outcomeKind = PullOutcomeKind.Dupe;
merges = Mathf.Max(1, GachaTuning.MergesPerDupe);
saveData.packageMerges += merges;
}
else
{
outcomeKind = PullOutcomeKind.Granted;
}
outcomeKind = duplicatePackage ? PullOutcomeKind.Dupe : PullOutcomeKind.Granted;
merges = cacheAwarded;
}
}
@@ -373,7 +365,7 @@ namespace KernelPanic.UI
SummaryTotals totals = CalculateTotals();
if (!animate)
{
summaryTotalsLabel.text = FormatTotals(totals.Units, totals.Merges, totals.LanguagesText);
summaryTotalsLabel.text = FormatTotals(totals.Units, totals.Merges, totals.Cache, totals.LanguagesText);
return;
}
@@ -387,7 +379,8 @@ namespace KernelPanic.UI
float t = capturedStep / (float)SummaryTickSteps;
int units = Mathf.RoundToInt(Mathf.Lerp(0, totals.Units, t));
int merges = Mathf.RoundToInt(Mathf.Lerp(0, totals.Merges, t));
summaryTotalsLabel.text = FormatTotals(units, merges, capturedStep == SummaryTickSteps ? totals.LanguagesText : "...");
int cache = Mathf.RoundToInt(Mathf.Lerp(0, totals.Cache, t));
summaryTotalsLabel.text = FormatTotals(units, merges, cache, capturedStep == SummaryTickSteps ? totals.LanguagesText : "...");
if (capturedStep == SummaryTickSteps)
{
summaryTotalsLabel.RemoveFromClassList("summary-tick");
@@ -400,6 +393,7 @@ namespace KernelPanic.UI
{
int units = 0;
int merges = 0;
int cache = 0;
HashSet<Language> languages = new();
for (int i = 0; i < rewards.Count; i++)
{
@@ -408,21 +402,30 @@ namespace KernelPanic.UI
units++;
}
if (rewards[i].RewardType == GachaRewardType.Package)
{
cache += rewards[i].MergesAwarded;
}
else
{
merges += rewards[i].MergesAwarded;
}
for (int languageIndex = 0; languageIndex < rewards[i].LanguagesUnlocked.Count; languageIndex++)
{
languages.Add(rewards[i].LanguagesUnlocked[languageIndex]);
}
}
return new SummaryTotals(units, merges, FormatLanguages(languages));
return new SummaryTotals(units, merges, cache, FormatLanguages(languages));
}
private static string FormatTotals(int units, int merges, string languagesText)
private static string FormatTotals(int units, int merges, int cache, string languagesText)
{
string unitText = units == 0 ? "none" : units.ToString();
string mergeText = merges == 0 ? "none" : merges.ToString();
return $"units gained: {unitText} merges: {mergeText} languages unlocked: {languagesText}";
string cacheText = cache == 0 ? "none" : cache.ToString();
return $"units gained: {unitText} merges: {mergeText} cache: {cacheText} languages unlocked: {languagesText}";
}
private static string FormatLanguages(HashSet<Language> languages)
@@ -468,9 +471,10 @@ namespace KernelPanic.UI
return collection;
}
private static PackageDefinition GrantRandomPackageByRarity(PackageDatabase packageDatabase, PlayerCollection collection, SaveData saveData, int rarity, out bool duplicate)
private static PackageDefinition GrantRandomPackageByRarity(PackageDatabase packageDatabase, PlayerCollection collection, SaveData saveData, int rarity, out bool duplicate, out int cacheAwarded)
{
duplicate = false;
cacheAwarded = 0;
int count = packageDatabase == null ? 0 : packageDatabase.CountByRarity(rarity);
if (count <= 0)
{
@@ -484,8 +488,15 @@ namespace KernelPanic.UI
return null;
}
duplicate = collection.IsPackageOwned(package.Id) || saveData.ownedPackageIds.Exists(id => string.Equals(id, package.Id, StringComparison.OrdinalIgnoreCase));
if (!duplicate)
duplicate = collection.IsPackageOwned(package.Id);
if (duplicate)
{
// Packages are unique-per-type: a dupe never creates a second instance, it
// auto-scraps into Cache immediately, mirroring the distro dupe -> Merges path.
cacheAwarded = PackageTuning.GetCacheForRarity(package.Rarity);
saveData.cacheBalance += cacheAwarded;
}
else
{
collection.AddPackageSilently(package);
saveData.ownedPackages.Add(new OwnedPackageSaveEntry { id = package.Id, upgradeLevel = 0 });
@@ -758,15 +769,17 @@ namespace KernelPanic.UI
private readonly struct SummaryTotals
{
public SummaryTotals(int units, int merges, string languagesText)
public SummaryTotals(int units, int merges, int cache, string languagesText)
{
Units = units;
Merges = merges;
Cache = cache;
LanguagesText = languagesText;
}
public int Units { get; }
public int Merges { get; }
public int Cache { get; }
public string LanguagesText { get; }
}
+63 -13
View File
@@ -140,6 +140,7 @@ namespace KernelPanic.UI
private Button _rootCreditExchangeCloseButton;
private Button _collectionUnitsButton;
private Button _collectionLanguagesButton;
private Button _collectionPackagesButton;
private readonly ScreenFrameController _runSetupFrame = new();
private readonly ScreenFrameController _collectionFrame = new();
private readonly ScreenFrameController _gachaFrame = new();
@@ -156,7 +157,7 @@ namespace KernelPanic.UI
private int _selectedCommandIndex;
private int _selectedPackageIndex;
private string _runSetupNotice;
private bool _collectionShowingLanguages;
private CollectionTopTab _collectionTab;
private float _bootIntroElapsed;
private int _bootIntroCharacterCount;
private bool _cursorVisible;
@@ -293,6 +294,7 @@ namespace KernelPanic.UI
_rootCreditExchangeCloseButton = _root.Q<Button>("RootCreditExchangeCloseButton");
_collectionUnitsButton = _root.Q<Button>("CollectionUnitsButton");
_collectionLanguagesButton = _root.Q<Button>("CollectionLanguagesButton");
_collectionPackagesButton = _root.Q<Button>("CollectionPackagesButton");
}
private void LoadSharedStyles()
@@ -415,6 +417,7 @@ namespace KernelPanic.UI
collectionScreen.ViewChanged += SyncCollectionFrame;
_collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
_collectionLanguagesButton.RegisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked);
_collectionPackagesButton.RegisterCallback<ClickEvent>(HandleCollectionPackagesTabClicked);
}
private void UnregisterCallbacks()
@@ -484,6 +487,7 @@ namespace KernelPanic.UI
collectionScreen.ViewChanged -= SyncCollectionFrame;
_collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
_collectionLanguagesButton.UnregisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked);
_collectionPackagesButton.UnregisterCallback<ClickEvent>(HandleCollectionPackagesTabClicked);
}
private void HandleRootCreditsToEntropyClicked()
@@ -652,9 +656,18 @@ namespace KernelPanic.UI
evt.StopPropagation();
}
private void HandleCollectionPackagesTabClicked(ClickEvent evt)
{
ShowCollectionPackages();
evt.StopPropagation();
}
private void LoadMetaState()
{
_saveData = _saveService.Load();
// Snapshot before the next EnsureLists() call below, which re-runs NormalizeOwnedPackages
// and would otherwise clear this (no new duplicates survive a second normalization pass).
List<CollapsedPackageDuplicate> collapsedPackageDuplicates = new(_saveData.collapsedPackageDuplicates);
_saveData.EnsureLists();
_wallet.SetBalance(_saveData.entropyBalance);
@@ -678,6 +691,7 @@ namespace KernelPanic.UI
}
}
MigrateCollapsedPackageDuplicatesToCache(collapsedPackageDuplicates);
LoadPackageLoadoutsFromSave();
for (int i = 0; i < _saveData.bannerPoolIds.Count; i++)
@@ -704,6 +718,18 @@ namespace KernelPanic.UI
SaveCurrentState();
}
private void MigrateCollapsedPackageDuplicatesToCache(List<CollapsedPackageDuplicate> collapsedPackageDuplicates)
{
foreach (CollapsedPackageDuplicate duplicate in collapsedPackageDuplicates)
{
PackageDefinition package = packageDatabase == null ? null : packageDatabase.FindById(duplicate.Id);
int rarity = package == null ? 1 : package.Rarity;
int cache = PackageTuning.GetCacheForRarity(rarity) * duplicate.ExtraCount;
_saveData.cacheBalance += cache;
Debug.Log($"Migrated {duplicate.ExtraCount} duplicate save copies of package '{duplicate.Id}' into +{cache} Cache (packages are unique-per-type; kept the highest-level instance).");
}
}
private void AddAllFourStarDistrosToBeginnerBannerPool()
{
if (distroDatabase == null)
@@ -769,9 +795,8 @@ namespace KernelPanic.UI
return;
}
_collectionShowingLanguages = false;
_collectionUnitsButton.EnableInClassList(SelectedClassName, true);
_collectionLanguagesButton.EnableInClassList(SelectedClassName, false);
_collectionTab = CollectionTopTab.Units;
ApplyCollectionTabButtons();
collectionScreen.RefreshUnits(_playerCollection.OwnedUnits);
collectionScreen.ShowUnitDetail(unit, false);
SyncCollectionFrame();
@@ -1917,31 +1942,49 @@ namespace KernelPanic.UI
private void ShowCollectionUnits()
{
_collectionShowingLanguages = false;
_collectionUnitsButton.EnableInClassList(SelectedClassName, true);
_collectionLanguagesButton.EnableInClassList(SelectedClassName, false);
_collectionTab = CollectionTopTab.Units;
ApplyCollectionTabButtons();
collectionScreen.RefreshUnits(_playerCollection.OwnedUnits);
SyncCollectionFrame();
}
private void ShowCollectionLanguages()
{
_collectionShowingLanguages = true;
_collectionUnitsButton.EnableInClassList(SelectedClassName, false);
_collectionLanguagesButton.EnableInClassList(SelectedClassName, true);
_collectionTab = CollectionTopTab.Languages;
ApplyCollectionTabButtons();
collectionScreen.ShowLanguages();
SyncCollectionFrame();
}
private void ShowCollectionPackages()
{
_collectionTab = CollectionTopTab.Packages;
ApplyCollectionTabButtons();
collectionScreen.ShowPackages();
SyncCollectionFrame();
}
private void ApplyCollectionTabButtons()
{
_collectionUnitsButton.EnableInClassList(SelectedClassName, _collectionTab == CollectionTopTab.Units);
_collectionLanguagesButton.EnableInClassList(SelectedClassName, _collectionTab == CollectionTopTab.Languages);
_collectionPackagesButton.EnableInClassList(SelectedClassName, _collectionTab == CollectionTopTab.Packages);
}
private void ToggleCollectionTab()
{
if (_collectionShowingLanguages)
switch (_collectionTab)
{
case CollectionTopTab.Units:
ShowCollectionLanguages();
return;
case CollectionTopTab.Languages:
ShowCollectionPackages();
return;
default:
ShowCollectionUnits();
return;
}
ShowCollectionLanguages();
}
private void SyncCollectionFrame()
@@ -2034,6 +2077,13 @@ namespace KernelPanic.UI
Settings
}
private enum CollectionTopTab
{
Units,
Languages,
Packages
}
private sealed class CommandMenuEntry
{
private readonly Label _cursor;
+1
View File
@@ -8,6 +8,7 @@
<ui:VisualElement class="collection-actions">
<ui:Button name="CollectionUnitsButton" text="units" focusable="false" class="collection-tab" />
<ui:Button name="CollectionLanguagesButton" text="languages" focusable="false" class="collection-tab" />
<ui:Button name="CollectionPackagesButton" text="packages" focusable="false" class="collection-tab" />
</ui:VisualElement>
<ui:VisualElement name="CollectionContent" class="collection-shell">
<ui:ScrollView name="CollectionList" class="collection-list" />