Add Packages inventory screen and auto-scrap duplicate package pulls

Packages are unique-per-type; duplicate pulls now auto-scrap into Cache
(mirroring the distro dupe->Merges path) instead of a dead-end merges
pool, and the pull reveal shows "-> N cache" for the dupe card. Save
loading collapses any pre-existing duplicate package entries to the
highest-level instance and grants Cache for the extras, logging the
migration.

Also adds a dedicated Packages tab in the Collection screen (master
list with slot filter/sort + affordability hints, detail panel with
upgrade/scrap actions) so the whole inventory can be managed from one
place, sharing state with the existing per-distro Packages sub-tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MQmck8EqqWxSq5uVZ4E7yN
This commit is contained in:
Claude
2026-07-09 09:04:46 +00:00
parent d0722c6bf9
commit 8c08b94d2b
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 string DisplayName { get; }
public GachaRewardType RewardType { get; } public GachaRewardType RewardType { get; }
public PullOutcomeKind OutcomeKind { 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 int MergesAwarded { get; }
public IReadOnlyList<Language> LanguagesUnlocked { get; } public IReadOnlyList<Language> LanguagesUnlocked { get; }
public bool Guaranteed { get; } public bool Guaranteed { get; }
@@ -139,11 +142,12 @@ namespace KernelPanic.Meta
{ {
get get
{ {
string dupeUnit = RewardType == GachaRewardType.Package ? "cache" : "merges";
return OutcomeKind switch return OutcomeKind switch
{ {
PullOutcomeKind.Granted => "NEW", PullOutcomeKind.Granted => "NEW",
PullOutcomeKind.Dupe => $"→ {MergesAwarded} merges", PullOutcomeKind.Dupe => $"→ {MergesAwarded} {dupeUnit}",
PullOutcomeKind.DupeOverflow => $"→ {MergesAwarded} merges", PullOutcomeKind.DupeOverflow => $"→ {MergesAwarded} {dupeUnit}",
_ => Guaranteed ? "guaranteed" : PityTriggered ? "pity" : string.Empty _ => 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. // Legacy pull-token field kept only so older saves deserialize cleanly.
public int limitedPullCurrency; public int limitedPullCurrency;
public int cacheBalance; public int cacheBalance;
// Legacy per-pull package-dupe pool. Package dupes now auto-scrap directly to Cache.
public int packageMerges; public int packageMerges;
// Legacy global balance kept only so older saves deserialize cleanly. // Legacy global balance kept only so older saves deserialize cleanly.
// New duplicate-pull merges live on OwnedUnitSaveEntry.merges. // New duplicate-pull merges live on OwnedUnitSaveEntry.merges.
@@ -84,6 +85,13 @@ namespace KernelPanic.Meta
public string lastRunDistroId; public string lastRunDistroId;
public List<DistroBestWaveSaveEntry> distroBestWaves = new(); 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() public static SaveData CreateDefault()
{ {
return new SaveData(); return new SaveData();
@@ -228,17 +236,36 @@ namespace KernelPanic.Meta
private void NormalizeOwnedPackages() private void NormalizeOwnedPackages()
{ {
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase); collapsedPackageDuplicates.Clear();
for (int i = ownedPackages.Count - 1; i >= 0; i--) 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]; 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; continue;
} }
entry.upgradeLevel = Math.Max(0, Math.Min(PackageTuning.MaxPackageLevel, entry.upgradeLevel)); 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(); ownedPackageIds.Clear();
@@ -407,6 +434,18 @@ namespace KernelPanic.Meta
public int upgradeLevel; 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] [Serializable]
public sealed class LastRunLoadoutSaveEntry public sealed class LastRunLoadoutSaveEntry
{ {
+305 -141
View File
@@ -45,6 +45,9 @@ namespace KernelPanic.UI
private int _selectedCardIndex; private int _selectedCardIndex;
private string _emptyCardMessage = "no cards installed"; private string _emptyCardMessage = "no cards installed";
private string _pendingScrapPackageId; private string _pendingScrapPackageId;
private IReadOnlyList<OwnedPackageInstance> _packageInventory = Array.Empty<OwnedPackageInstance>();
private int _selectedPackageInventoryIndex;
private PackageSlot? _packageInventoryFilter;
public event Action ViewChanged; public event Action ViewChanged;
@@ -98,6 +101,16 @@ namespace KernelPanic.UI
ViewChanged?.Invoke(); 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() public bool BackFromSubview()
{ {
if (_mode == CollectionMode.UnitDetail) if (_mode == CollectionMode.UnitDetail)
@@ -147,6 +160,12 @@ namespace KernelPanic.UI
return; return;
} }
if (_mode == CollectionMode.Packages)
{
SelectPackageInventory(_selectedPackageInventoryIndex + delta);
return;
}
if (_mode == CollectionMode.UnitDetail) if (_mode == CollectionMode.UnitDetail)
{ {
return; return;
@@ -309,6 +328,268 @@ namespace KernelPanic.UI
SelectLanguage(_selectedLanguageIndex); 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) private VisualElement BuildUnitOverviewRow(DistroDefinition unit)
{ {
VisualElement row = new(); 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) private void TogglePackage(DistroDefinition unit, PackageSlot slot, PackageDefinition package, bool isEquipped)
{ {
if (unit == null || package == null || _packageLoadout == null) 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) private void UpgradePackage(OwnedPackageInstance package)
{ {
bool inventoryMode = _mode == CollectionMode.Packages;
PackageUpgradeResult result = _tryUpgradePackage == null ? new PackageUpgradeResult(false, PackageUpgradeFailureReason.NotOwned, 0, 0, 0) : _tryUpgradePackage(package); PackageUpgradeResult result = _tryUpgradePackage == null ? new PackageUpgradeResult(false, PackageUpgradeFailureReason.NotOwned, 0, 0, 0) : _tryUpgradePackage(package);
RenderUnitDetail(_units[_selectedUnitIndex]); RefreshAfterPackageAction(inventoryMode);
if (!result.Success) if (!result.Success)
{ {
_detail.Add(BuildDetailLine("package upgrade", FormatPackageUpgradeFailure(result.FailureReason))); _detail.Add(BuildDetailLine("package upgrade", FormatPackageUpgradeFailure(result.FailureReason)));
@@ -1068,22 +1212,41 @@ namespace KernelPanic.UI
return; return;
} }
bool inventoryMode = _mode == CollectionMode.Packages;
if (!string.Equals(_pendingScrapPackageId, package.PackageId, StringComparison.OrdinalIgnoreCase)) if (!string.Equals(_pendingScrapPackageId, package.PackageId, StringComparison.OrdinalIgnoreCase))
{ {
_pendingScrapPackageId = package.PackageId; _pendingScrapPackageId = package.PackageId;
RenderUnitDetail(_units[_selectedUnitIndex]); RefreshAfterPackageAction(inventoryMode);
return; return;
} }
PackageScrapResult result = _tryScrapPackage == null ? new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0) : _tryScrapPackage(package); PackageScrapResult result = _tryScrapPackage == null ? new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0) : _tryScrapPackage(package);
_pendingScrapPackageId = null; _pendingScrapPackageId = null;
RenderUnitDetail(_units[_selectedUnitIndex]); RefreshAfterPackageAction(inventoryMode);
if (!result.Success) if (!result.Success)
{ {
_detail.Add(BuildDetailLine("package scrap", FormatPackageScrapFailure(result.FailureReason))); _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) private void RenderLanguageDetail(LanguageCatalogEntry language)
{ {
_detail.Clear(); _detail.Clear();
@@ -1579,6 +1742,7 @@ namespace KernelPanic.UI
{ {
Units, Units,
Languages, Languages,
Packages,
UnitDetail, UnitDetail,
CardSubview CardSubview
} }
+34 -21
View File
@@ -297,20 +297,12 @@ namespace KernelPanic.UI
string displayName = reward.DisplayName; string displayName = reward.DisplayName;
if (reward.RewardType == GachaRewardType.Package) 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) if (package != null)
{ {
displayName = string.IsNullOrWhiteSpace(package.DisplayName) ? package.Id : package.DisplayName; displayName = string.IsNullOrWhiteSpace(package.DisplayName) ? package.Id : package.DisplayName;
if (duplicatePackage) outcomeKind = duplicatePackage ? PullOutcomeKind.Dupe : PullOutcomeKind.Granted;
{ merges = cacheAwarded;
outcomeKind = PullOutcomeKind.Dupe;
merges = Mathf.Max(1, GachaTuning.MergesPerDupe);
saveData.packageMerges += merges;
}
else
{
outcomeKind = PullOutcomeKind.Granted;
}
} }
} }
@@ -373,7 +365,7 @@ namespace KernelPanic.UI
SummaryTotals totals = CalculateTotals(); SummaryTotals totals = CalculateTotals();
if (!animate) if (!animate)
{ {
summaryTotalsLabel.text = FormatTotals(totals.Units, totals.Merges, totals.LanguagesText); summaryTotalsLabel.text = FormatTotals(totals.Units, totals.Merges, totals.Cache, totals.LanguagesText);
return; return;
} }
@@ -387,7 +379,8 @@ namespace KernelPanic.UI
float t = capturedStep / (float)SummaryTickSteps; float t = capturedStep / (float)SummaryTickSteps;
int units = Mathf.RoundToInt(Mathf.Lerp(0, totals.Units, t)); int units = Mathf.RoundToInt(Mathf.Lerp(0, totals.Units, t));
int merges = Mathf.RoundToInt(Mathf.Lerp(0, totals.Merges, 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) if (capturedStep == SummaryTickSteps)
{ {
summaryTotalsLabel.RemoveFromClassList("summary-tick"); summaryTotalsLabel.RemoveFromClassList("summary-tick");
@@ -400,6 +393,7 @@ namespace KernelPanic.UI
{ {
int units = 0; int units = 0;
int merges = 0; int merges = 0;
int cache = 0;
HashSet<Language> languages = new(); HashSet<Language> languages = new();
for (int i = 0; i < rewards.Count; i++) for (int i = 0; i < rewards.Count; i++)
{ {
@@ -408,21 +402,30 @@ namespace KernelPanic.UI
units++; units++;
} }
merges += rewards[i].MergesAwarded; 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++) for (int languageIndex = 0; languageIndex < rewards[i].LanguagesUnlocked.Count; languageIndex++)
{ {
languages.Add(rewards[i].LanguagesUnlocked[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 unitText = units == 0 ? "none" : units.ToString();
string mergeText = merges == 0 ? "none" : merges.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) private static string FormatLanguages(HashSet<Language> languages)
@@ -468,9 +471,10 @@ namespace KernelPanic.UI
return collection; 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; duplicate = false;
cacheAwarded = 0;
int count = packageDatabase == null ? 0 : packageDatabase.CountByRarity(rarity); int count = packageDatabase == null ? 0 : packageDatabase.CountByRarity(rarity);
if (count <= 0) if (count <= 0)
{ {
@@ -484,8 +488,15 @@ namespace KernelPanic.UI
return null; return null;
} }
duplicate = collection.IsPackageOwned(package.Id) || saveData.ownedPackageIds.Exists(id => string.Equals(id, package.Id, StringComparison.OrdinalIgnoreCase)); duplicate = collection.IsPackageOwned(package.Id);
if (!duplicate) 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); collection.AddPackageSilently(package);
saveData.ownedPackages.Add(new OwnedPackageSaveEntry { id = package.Id, upgradeLevel = 0 }); saveData.ownedPackages.Add(new OwnedPackageSaveEntry { id = package.Id, upgradeLevel = 0 });
@@ -758,15 +769,17 @@ namespace KernelPanic.UI
private readonly struct SummaryTotals private readonly struct SummaryTotals
{ {
public SummaryTotals(int units, int merges, string languagesText) public SummaryTotals(int units, int merges, int cache, string languagesText)
{ {
Units = units; Units = units;
Merges = merges; Merges = merges;
Cache = cache;
LanguagesText = languagesText; LanguagesText = languagesText;
} }
public int Units { get; } public int Units { get; }
public int Merges { get; } public int Merges { get; }
public int Cache { get; }
public string LanguagesText { get; } public string LanguagesText { get; }
} }
+65 -15
View File
@@ -140,6 +140,7 @@ namespace KernelPanic.UI
private Button _rootCreditExchangeCloseButton; private Button _rootCreditExchangeCloseButton;
private Button _collectionUnitsButton; private Button _collectionUnitsButton;
private Button _collectionLanguagesButton; private Button _collectionLanguagesButton;
private Button _collectionPackagesButton;
private readonly ScreenFrameController _runSetupFrame = new(); private readonly ScreenFrameController _runSetupFrame = new();
private readonly ScreenFrameController _collectionFrame = new(); private readonly ScreenFrameController _collectionFrame = new();
private readonly ScreenFrameController _gachaFrame = new(); private readonly ScreenFrameController _gachaFrame = new();
@@ -156,7 +157,7 @@ namespace KernelPanic.UI
private int _selectedCommandIndex; private int _selectedCommandIndex;
private int _selectedPackageIndex; private int _selectedPackageIndex;
private string _runSetupNotice; private string _runSetupNotice;
private bool _collectionShowingLanguages; private CollectionTopTab _collectionTab;
private float _bootIntroElapsed; private float _bootIntroElapsed;
private int _bootIntroCharacterCount; private int _bootIntroCharacterCount;
private bool _cursorVisible; private bool _cursorVisible;
@@ -293,6 +294,7 @@ namespace KernelPanic.UI
_rootCreditExchangeCloseButton = _root.Q<Button>("RootCreditExchangeCloseButton"); _rootCreditExchangeCloseButton = _root.Q<Button>("RootCreditExchangeCloseButton");
_collectionUnitsButton = _root.Q<Button>("CollectionUnitsButton"); _collectionUnitsButton = _root.Q<Button>("CollectionUnitsButton");
_collectionLanguagesButton = _root.Q<Button>("CollectionLanguagesButton"); _collectionLanguagesButton = _root.Q<Button>("CollectionLanguagesButton");
_collectionPackagesButton = _root.Q<Button>("CollectionPackagesButton");
} }
private void LoadSharedStyles() private void LoadSharedStyles()
@@ -415,6 +417,7 @@ namespace KernelPanic.UI
collectionScreen.ViewChanged += SyncCollectionFrame; collectionScreen.ViewChanged += SyncCollectionFrame;
_collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked); _collectionUnitsButton.RegisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
_collectionLanguagesButton.RegisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked); _collectionLanguagesButton.RegisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked);
_collectionPackagesButton.RegisterCallback<ClickEvent>(HandleCollectionPackagesTabClicked);
} }
private void UnregisterCallbacks() private void UnregisterCallbacks()
@@ -484,6 +487,7 @@ namespace KernelPanic.UI
collectionScreen.ViewChanged -= SyncCollectionFrame; collectionScreen.ViewChanged -= SyncCollectionFrame;
_collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked); _collectionUnitsButton.UnregisterCallback<ClickEvent>(HandleCollectionUnitsTabClicked);
_collectionLanguagesButton.UnregisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked); _collectionLanguagesButton.UnregisterCallback<ClickEvent>(HandleCollectionLanguagesTabClicked);
_collectionPackagesButton.UnregisterCallback<ClickEvent>(HandleCollectionPackagesTabClicked);
} }
private void HandleRootCreditsToEntropyClicked() private void HandleRootCreditsToEntropyClicked()
@@ -652,9 +656,18 @@ namespace KernelPanic.UI
evt.StopPropagation(); evt.StopPropagation();
} }
private void HandleCollectionPackagesTabClicked(ClickEvent evt)
{
ShowCollectionPackages();
evt.StopPropagation();
}
private void LoadMetaState() private void LoadMetaState()
{ {
_saveData = _saveService.Load(); _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(); _saveData.EnsureLists();
_wallet.SetBalance(_saveData.entropyBalance); _wallet.SetBalance(_saveData.entropyBalance);
@@ -678,6 +691,7 @@ namespace KernelPanic.UI
} }
} }
MigrateCollapsedPackageDuplicatesToCache(collapsedPackageDuplicates);
LoadPackageLoadoutsFromSave(); LoadPackageLoadoutsFromSave();
for (int i = 0; i < _saveData.bannerPoolIds.Count; i++) for (int i = 0; i < _saveData.bannerPoolIds.Count; i++)
@@ -704,6 +718,18 @@ namespace KernelPanic.UI
SaveCurrentState(); 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() private void AddAllFourStarDistrosToBeginnerBannerPool()
{ {
if (distroDatabase == null) if (distroDatabase == null)
@@ -769,9 +795,8 @@ namespace KernelPanic.UI
return; return;
} }
_collectionShowingLanguages = false; _collectionTab = CollectionTopTab.Units;
_collectionUnitsButton.EnableInClassList(SelectedClassName, true); ApplyCollectionTabButtons();
_collectionLanguagesButton.EnableInClassList(SelectedClassName, false);
collectionScreen.RefreshUnits(_playerCollection.OwnedUnits); collectionScreen.RefreshUnits(_playerCollection.OwnedUnits);
collectionScreen.ShowUnitDetail(unit, false); collectionScreen.ShowUnitDetail(unit, false);
SyncCollectionFrame(); SyncCollectionFrame();
@@ -1917,31 +1942,49 @@ namespace KernelPanic.UI
private void ShowCollectionUnits() private void ShowCollectionUnits()
{ {
_collectionShowingLanguages = false; _collectionTab = CollectionTopTab.Units;
_collectionUnitsButton.EnableInClassList(SelectedClassName, true); ApplyCollectionTabButtons();
_collectionLanguagesButton.EnableInClassList(SelectedClassName, false);
collectionScreen.RefreshUnits(_playerCollection.OwnedUnits); collectionScreen.RefreshUnits(_playerCollection.OwnedUnits);
SyncCollectionFrame(); SyncCollectionFrame();
} }
private void ShowCollectionLanguages() private void ShowCollectionLanguages()
{ {
_collectionShowingLanguages = true; _collectionTab = CollectionTopTab.Languages;
_collectionUnitsButton.EnableInClassList(SelectedClassName, false); ApplyCollectionTabButtons();
_collectionLanguagesButton.EnableInClassList(SelectedClassName, true);
collectionScreen.ShowLanguages(); collectionScreen.ShowLanguages();
SyncCollectionFrame(); 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() private void ToggleCollectionTab()
{ {
if (_collectionShowingLanguages) switch (_collectionTab)
{ {
ShowCollectionUnits(); case CollectionTopTab.Units:
return; ShowCollectionLanguages();
return;
case CollectionTopTab.Languages:
ShowCollectionPackages();
return;
default:
ShowCollectionUnits();
return;
} }
ShowCollectionLanguages();
} }
private void SyncCollectionFrame() private void SyncCollectionFrame()
@@ -2034,6 +2077,13 @@ namespace KernelPanic.UI
Settings Settings
} }
private enum CollectionTopTab
{
Units,
Languages,
Packages
}
private sealed class CommandMenuEntry private sealed class CommandMenuEntry
{ {
private readonly Label _cursor; private readonly Label _cursor;
+1
View File
@@ -8,6 +8,7 @@
<ui:VisualElement class="collection-actions"> <ui:VisualElement class="collection-actions">
<ui:Button name="CollectionUnitsButton" text="units" focusable="false" class="collection-tab" /> <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="CollectionLanguagesButton" text="languages" focusable="false" class="collection-tab" />
<ui:Button name="CollectionPackagesButton" text="packages" focusable="false" class="collection-tab" />
</ui:VisualElement> </ui:VisualElement>
<ui:VisualElement name="CollectionContent" class="collection-shell"> <ui:VisualElement name="CollectionContent" class="collection-shell">
<ui:ScrollView name="CollectionList" class="collection-list" /> <ui:ScrollView name="CollectionList" class="collection-list" />