From 8c08b94d2b3bed3d7e13fed66e9807023648d871 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 09:04:46 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01MQmck8EqqWxSq5uVZ4E7yN --- Assets/Scripts/Meta/GachaPullContext.cs | 8 +- Assets/Scripts/Meta/SaveService.cs | 47 +- .../Scripts/UI/CollectionScreenController.cs | 446 ++++++++++++------ Assets/Scripts/UI/GamblingSceneController.cs | 55 ++- Assets/Scripts/UI/MainMenuController.cs | 80 +++- Assets/UI/CollectionScreen.uxml | 1 + 6 files changed, 454 insertions(+), 183 deletions(-) diff --git a/Assets/Scripts/Meta/GachaPullContext.cs b/Assets/Scripts/Meta/GachaPullContext.cs index d46f80d..614f234 100644 --- a/Assets/Scripts/Meta/GachaPullContext.cs +++ b/Assets/Scripts/Meta/GachaPullContext.cs @@ -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 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 }; } diff --git a/Assets/Scripts/Meta/SaveService.cs b/Assets/Scripts/Meta/SaveService.cs index 0c0fde3..1c2b0f1 100644 --- a/Assets/Scripts/Meta/SaveService.cs +++ b/Assets/Scripts/Meta/SaveService.cs @@ -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 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 collapsedPackageDuplicates = new(); + public static SaveData CreateDefault() { return new SaveData(); @@ -228,17 +236,36 @@ namespace KernelPanic.Meta private void NormalizeOwnedPackages() { - HashSet seen = new(StringComparer.OrdinalIgnoreCase); - for (int i = ownedPackages.Count - 1; i >= 0; i--) + collapsedPackageDuplicates.Clear(); + Dictionary highestById = new(StringComparer.OrdinalIgnoreCase); + Dictionary 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(highestById.Values); + foreach (KeyValuePair 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 { diff --git a/Assets/Scripts/UI/CollectionScreenController.cs b/Assets/Scripts/UI/CollectionScreenController.cs index 7990230..5e4d272 100644 --- a/Assets/Scripts/UI/CollectionScreenController.cs +++ b/Assets/Scripts/UI/CollectionScreenController.cs @@ -45,6 +45,9 @@ namespace KernelPanic.UI private int _selectedCardIndex; private string _emptyCardMessage = "no cards installed"; private string _pendingScrapPackageId; + private IReadOnlyList _packageInventory = Array.Empty(); + 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 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(_ => SelectPackageInventory(index)); + row.RegisterCallback(_ => 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 BuildSortedOwnedPackages(PackageSlot? filter) + { + List 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 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(_ => 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 } diff --git a/Assets/Scripts/UI/GamblingSceneController.cs b/Assets/Scripts/UI/GamblingSceneController.cs index ec316f0..004e135 100644 --- a/Assets/Scripts/UI/GamblingSceneController.cs +++ b/Assets/Scripts/UI/GamblingSceneController.cs @@ -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 languages = new(); for (int i = 0; i < rewards.Count; i++) { @@ -408,21 +402,30 @@ namespace KernelPanic.UI 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++) { 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 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; } } diff --git a/Assets/Scripts/UI/MainMenuController.cs b/Assets/Scripts/UI/MainMenuController.cs index e62edc9..15964fa 100644 --- a/Assets/Scripts/UI/MainMenuController.cs +++ b/Assets/Scripts/UI/MainMenuController.cs @@ -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