using System; using System.Collections.Generic; using KernelPanic.Core; using KernelPanic.Data; using KernelPanic.Meta; using KernelPanic.Run; using UnityEngine; using UnityEngine.TextCore.Text; using UnityEngine.UIElements; namespace KernelPanic.UI { /// /// Renders Collection tabs and the pushed read-only card subview. /// [Serializable] public sealed class CollectionScreenController { private readonly List _rows = new(); private VisualElement _list; private ScrollView _detailScroll; private VisualElement _detail; private VisualElement _root; private VisualElement _packageEquipOverlay; private VisualElement _packageEquipDialog; private FontAsset _monospaceFont; private LanguageDeckDatabase _languageDeckDatabase; private CardDatabase _cardDatabase; private PlayerCollection _playerCollection; private PackageDatabase _packageDatabase; private PackageLoadout _packageLoadout; private Action _packageLoadoutChanged; private Func _getSaveData; private Func _tryUpgradePackage; private Func _tryScrapPackage; private Func _getMergesBalance; private Func _tryUpgradeUnit; private IReadOnlyList _units = Array.Empty(); private IReadOnlyList _languages = LanguageCatalog.All; private IReadOnlyList _cards = Array.Empty(); private CollectionMode _mode; private CollectionMode _returnMode; private UnitDetailTab _unitDetailTab = UnitDetailTab.Overview; private PackageSlot? _packagePickerSlot; private bool _unitDetailReturnsToOverview = true; private DistroDefinition _equipWindowUnit; private PackageSlot _equipWindowSlot; private int _selectedUnitIndex; private int _selectedLanguageIndex; private int _selectedCardIndex; private string _emptyCardMessage = "no cards installed"; private string _pendingScrapPackageId; private string _packageSlotNotice; private IReadOnlyList _packageInventory = Array.Empty(); private int _selectedPackageInventoryIndex; private PackageSlot? _packageInventoryFilter; public event Action ViewChanged; public void Bind(VisualElement root, FontAsset artFont, LanguageDeckDatabase deckDatabase, CardDatabase cardsDatabase, PlayerCollection collection, Func getMergesBalance = null, Func tryUpgradeUnit = null, PackageDatabase packageDatabase = null, PackageLoadout packageLoadout = null, Action packageLoadoutChanged = null, Func getSaveData = null, Func tryUpgradePackage = null, Func tryScrapPackage = null) { _monospaceFont = artFont; _root = root; _languageDeckDatabase = deckDatabase; _cardDatabase = cardsDatabase; _playerCollection = collection; _getMergesBalance = getMergesBalance; _tryUpgradeUnit = tryUpgradeUnit; _packageDatabase = packageDatabase; _packageLoadout = packageLoadout; _packageLoadoutChanged = packageLoadoutChanged; _getSaveData = getSaveData; _tryUpgradePackage = tryUpgradePackage; _tryScrapPackage = tryScrapPackage; _list = root.Q("CollectionList"); _detailScroll = root.Q("CollectionDetail"); _detail = _detailScroll?.contentContainer; EnsurePackageEquipOverlay(); } public bool IsCardSubview => _mode == CollectionMode.CardSubview; public bool IsUnitDetail => _mode == CollectionMode.UnitDetail; public bool IsPushedSubview => _mode == CollectionMode.UnitDetail || _mode == CollectionMode.CardSubview; public string CurrentTitle { get; private set; } = "$ ls ~/collection"; public string CurrentHint { get; private set; } = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; public void RefreshUnits(IReadOnlyList ownedUnits) { _units = ownedUnits ?? Array.Empty(); ShowUnits(); } public void ShowUnits() { ClosePackageEquipWindow(); _mode = CollectionMode.Units; CurrentTitle = "$ ls ~/collection"; CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; RenderUnits(); ViewChanged?.Invoke(); } public void ShowLanguages() { ClosePackageEquipWindow(); _mode = CollectionMode.Languages; CurrentTitle = "$ ls ~/collection/languages"; CurrentHint = "[esc] back [left/right] tabs [tab] tabs [arrows] navigate [enter] select"; RenderLanguages(); ViewChanged?.Invoke(); } public void ShowPackages() { ClosePackageEquipWindow(); _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 (_packageEquipOverlay != null && !_packageEquipOverlay.ClassListContains("hidden")) { ClosePackageEquipWindow(); return true; } if (_mode == CollectionMode.UnitDetail) { _packagePickerSlot = null; _packageSlotNotice = null; if (_unitDetailReturnsToOverview) { ShowUnits(); return true; } return false; } if (_mode != CollectionMode.CardSubview) { return false; } if (_returnMode == CollectionMode.Languages) { ShowLanguages(); } else if (_returnMode == CollectionMode.UnitDetail) { OpenUnitDetail(_units[_selectedUnitIndex], false, _unitDetailReturnsToOverview); } else { ShowUnits(); } return true; } public void SelectRelative(int delta) { if (_mode == CollectionMode.Units) { SelectUnit(_selectedUnitIndex + delta); return; } if (_mode == CollectionMode.Languages) { SelectLanguage(_selectedLanguageIndex + delta); return; } if (_mode == CollectionMode.Packages) { SelectPackageInventory(_selectedPackageInventoryIndex + delta); return; } if (_mode == CollectionMode.UnitDetail) { return; } SelectCard(_selectedCardIndex + delta); } public void ActivateSelected() { if (_mode == CollectionMode.Units && _units.Count > 0) { OpenUnitDetail(_units[_selectedUnitIndex], true); return; } if (_mode == CollectionMode.Languages && _languages.Count > 0) { LanguageCatalogEntry language = _languages[_selectedLanguageIndex]; if (LanguageUnlock.IsUnlocked(language.Language, _playerCollection)) { OpenLanguageDeck(language); } } if (_mode == CollectionMode.UnitDetail && _units.Count > 0) { DistroDefinition unit = _units[_selectedUnitIndex]; switch (_unitDetailTab) { case UnitDetailTab.Upgrade: UpgradeUnit(unit); break; case UnitDetailTab.Packages: _packagePickerSlot ??= PackageSlot.Kernel; OpenPackageEquipWindow(unit, _packagePickerSlot.Value); break; case UnitDetailTab.Cards: OpenUnitCards(unit); break; } } } public void SwitchUnitDetailTabRelative(int delta) { if (_mode != CollectionMode.UnitDetail || _units.Count == 0) { return; } int count = Enum.GetValues(typeof(UnitDetailTab)).Length; _unitDetailTab = (UnitDetailTab)(((int)_unitDetailTab + delta + count) % count); _packagePickerSlot = null; _packageSlotNotice = null; RenderUnitDetail(_units[_selectedUnitIndex]); ViewChanged?.Invoke(); } public void ShowUnitDetail(DistroDefinition unit, bool returnToOverview) { SelectUnitById(unit?.Id); OpenUnitDetail(unit, true, returnToOverview); } private void RenderUnits() { if (_list == null || _detail == null) { return; } _rows.Clear(); _list.Clear(); _list.AddToClassList("hidden"); _detailScroll?.AddToClassList("collection-detail-full"); if (_detailScroll != null) { _detailScroll.verticalScrollerVisibility = ScrollerVisibility.Auto; } _detail.Clear(); if (_units.Count == 0) { _detail.Add(new Label("no units installed") { name = "CollectionEmptyTitle" }); _detail.Add(new Label("run: curl gacha.sh | sh") { name = "CollectionEmptyHint" }); return; } Label title = new("installed units"); title.AddToClassList("detail-section-title"); _detail.Add(title); VisualElement roster = new(); roster.AddToClassList("unit-overview-roster"); _detail.Add(roster); _selectedUnitIndex = Mathf.Clamp(_selectedUnitIndex, 0, _units.Count - 1); for (int i = 0; i < _units.Count; i++) { int index = i; DistroDefinition unit = _units[i]; VisualElement row = BuildUnitOverviewRow(unit); row.RegisterCallback(_ => SelectUnit(index)); row.RegisterCallback(_ => { SelectUnit(index); OpenUnitDetail(unit, true, true); }); roster.Add(row); _rows.Add(row); } ApplySelection(_selectedUnitIndex); } private void RenderLanguages() { 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; } _selectedLanguageIndex = Mathf.Clamp(_selectedLanguageIndex, 0, _languages.Count - 1); for (int i = 0; i < _languages.Count; i++) { int index = i; LanguageCatalogEntry language = _languages[i]; bool unlocked = LanguageUnlock.IsUnlocked(language.Language, _playerCollection); VisualElement row = new(); row.AddToClassList("collection-row"); row.EnableInClassList("locked", !unlocked); row.RegisterCallback(_ => SelectLanguage(index)); row.RegisterCallback(_ => SelectLanguage(index)); Label name = new(unlocked ? language.DisplayName : $"{language.DisplayName} [locked]"); name.AddToClassList("collection-row-name"); Label tag = new(language.IdentityTag); tag.AddToClassList("collection-row-languages"); row.Add(name); row.Add(tag); _list.Add(row); _rows.Add(row); } 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(); row.AddToClassList("collection-row"); row.AddToClassList("unit-overview-row"); VisualElement top = new(); top.AddToClassList("unit-overview-top"); Label name = new(DistroPresentation.DisplayName(unit)); name.AddToClassList("collection-row-name"); name.style.color = new StyleColor(unit.AccentColor); top.Add(name); RarityStyle rarity = RarityPresentation.ForStars(GetDistroStars(unit)); Label rarityLabel = new(rarity.Badge); rarityLabel.AddToClassList("unit-overview-rarity"); rarityLabel.AddToClassList(rarity.ClassName); top.Add(rarityLabel); if (CanUpgradeUnit(unit)) { Label upgrade = new("upgrade"); upgrade.AddToClassList("unit-overview-upgrade"); top.Add(upgrade); } row.Add(top); VisualElement meta = new(); meta.AddToClassList("unit-overview-meta"); AddLanguageTag(meta, unit.PrimaryLanguage); AddLanguageTag(meta, unit.SecondaryLanguage); row.Add(meta); int version = GetUnitVersion(unit); Label release = new($"{DistroPresentation.DisplayName(unit)} {DistroVersionCatalog.GetReleaseLabel(unit.Id, version)} · v{version}/{GachaTuning.MaxVersion}"); release.AddToClassList("unit-overview-release"); row.Add(release); VisualElement slots = new(); slots.AddToClassList("unit-overview-slots"); AddPackageSlotDot(slots, unit, PackageSlot.Kernel); AddPackageSlotDot(slots, unit, PackageSlot.Runtime); AddPackageSlotDot(slots, unit, PackageSlot.Daemon); row.Add(slots); return row; } private void AddLanguageTag(VisualElement target, Language language) { Label tag = new(language.ToString()); tag.AddToClassList("unit-language-tag"); tag.style.color = new StyleColor(GetLanguageColor(language)); target.Add(tag); } private void AddPackageSlotDot(VisualElement target, DistroDefinition unit, PackageSlot slot) { PackageDefinition package = _packageLoadout == null || unit == null ? null : _packageLoadout.GetEquippedPackage(unit.Id, slot); VisualElement dot = new(); dot.AddToClassList("unit-package-dot"); dot.EnableInClassList("empty", package == null); if (package != null) { RarityStyle rarity = RarityPresentation.ForStars(package.Rarity); dot.AddToClassList(rarity.ClassName); dot.style.backgroundColor = new StyleColor(rarity.Color); } target.Add(dot); } private bool CanUpgradeUnit(DistroDefinition unit) { if (unit == null) { return false; } int version = GetUnitVersion(unit); if (version >= GachaTuning.MaxVersion) { return false; } int merges = _getMergesBalance?.Invoke(unit) ?? 0; return merges >= GachaTuning.GetVersionUpgradeCost(version + 1); } private int GetUnitVersion(DistroDefinition unit) { return unit == null || _playerCollection == null ? 1 : Mathf.Clamp(_playerCollection.GetVersion(unit.Id), 1, GachaTuning.MaxVersion); } private static int GetDistroStars(DistroDefinition unit) { return unit != null && string.Equals(unit.Id, "arch", StringComparison.OrdinalIgnoreCase) ? 5 : 4; } private static Color GetLanguageColor(Language language) { return language switch { Language.C => new Color(0.55f, 0.85f, 1f), Language.CPlusPlus => new Color(0.48f, 0.72f, 1f), Language.Rust => new Color(1f, 0.55f, 0.35f), Language.Python => new Color(1f, 0.82f, 0.35f), Language.JavaScript => new Color(1f, 0.9f, 0.28f), Language.TypeScript => new Color(0.4f, 0.72f, 1f), Language.Haskell => new Color(0.75f, 0.55f, 1f), Language.Assembly => new Color(0.85f, 0.85f, 0.85f), Language.Java => new Color(1f, 0.58f, 0.42f), Language.Go => new Color(0.35f, 0.95f, 1f), Language.Ruby => new Color(1f, 0.35f, 0.45f), Language.Php => new Color(0.68f, 0.68f, 1f), _ => new Color(0.5f, 0.62f, 0.54f) }; } private void SelectUnit(int index) { if (_units.Count == 0) { return; } _selectedUnitIndex = Mathf.Clamp(index, 0, _units.Count - 1); ApplySelection(_selectedUnitIndex); } private void SelectUnitById(string unitId) { if (string.IsNullOrWhiteSpace(unitId)) { return; } for (int i = 0; i < _units.Count; i++) { DistroDefinition unit = _units[i]; if (unit != null && string.Equals(unit.Id, unitId, StringComparison.OrdinalIgnoreCase)) { _selectedUnitIndex = i; return; } } } private void SelectLanguage(int index) { _selectedLanguageIndex = Mathf.Clamp(index, 0, _languages.Count - 1); ApplySelection(_selectedLanguageIndex); RenderLanguageDetail(_languages[_selectedLanguageIndex]); } private void SelectCard(int index) { if (_cards.Count == 0) { return; } _selectedCardIndex = Mathf.Clamp(index, 0, _cards.Count - 1); ApplySelection(_selectedCardIndex); RenderCardDetail(_cards[_selectedCardIndex]); } private void ApplySelection(int selectedIndex) { for (int i = 0; i < _rows.Count; i++) { _rows[i].EnableInClassList("selected", i == selectedIndex); } } private void OpenUnitDetail(DistroDefinition unit, bool notify, bool returnToOverview = true) { if (unit == null) { return; } if (_mode != CollectionMode.UnitDetail) { _unitDetailTab = UnitDetailTab.Overview; _packagePickerSlot = null; _packageSlotNotice = null; } _unitDetailReturnsToOverview = returnToOverview; _mode = CollectionMode.UnitDetail; CurrentTitle = $"$ cat ~/units/{unit.Id}"; CurrentHint = GetUnitDetailHint(); RenderUnitDetail(unit); if (notify) { ViewChanged?.Invoke(); } } private void RenderUnitDetail(DistroDefinition unit) { _list.AddToClassList("hidden"); _detailScroll?.AddToClassList("collection-detail-full"); if (_detailScroll != null) { _detailScroll.verticalScrollerVisibility = ScrollerVisibility.Hidden; } _detail.Clear(); CurrentHint = GetUnitDetailHint(); _detail.Add(BuildUnitDetailHeader(unit)); _detail.Add(BuildUnitDetailTabs(unit)); ScrollView bodyScroll = new(); bodyScroll.AddToClassList("unit-detail-tab-body"); VisualElement body = bodyScroll.contentContainer; switch (_unitDetailTab) { case UnitDetailTab.Upgrade: AddUnitUpgradeTab(body, unit); break; case UnitDetailTab.Packages: AddUnitPackagesTab(body, unit); break; case UnitDetailTab.Cards: AddUnitCardsTab(body, unit); break; default: AddUnitOverviewTab(body, unit); break; } _detail.Add(bodyScroll); } private VisualElement BuildUnitDetailHeader(DistroDefinition unit) { VisualElement header = new(); header.AddToClassList("unit-detail-header"); VisualElement nameBlock = new(); nameBlock.AddToClassList("unit-detail-header-copy"); int version = GetUnitVersion(unit); Label name = new($"{DistroPresentation.DisplayName(unit)} {DistroVersionCatalog.GetReleaseLabel(unit.Id, version)} · v{version}/{GachaTuning.MaxVersion}"); name.AddToClassList("collection-detail-name"); name.style.color = new StyleColor(unit.AccentColor); nameBlock.Add(name); VisualElement languages = new(); languages.AddToClassList("unit-overview-meta"); AddLanguageTag(languages, unit.PrimaryLanguage); AddLanguageTag(languages, unit.SecondaryLanguage); nameBlock.Add(languages); header.Add(nameBlock); RarityStyle rarity = RarityPresentation.ForStars(GetDistroStars(unit)); Label rarityLabel = new(rarity.Stars); rarityLabel.AddToClassList("unit-overview-rarity"); rarityLabel.AddToClassList(rarity.ClassName); header.Add(rarityLabel); return header; } private VisualElement BuildUnitDetailTabs(DistroDefinition unit) { VisualElement tabs = new(); tabs.AddToClassList("unit-detail-tabs"); AddUnitDetailTabButton(tabs, UnitDetailTab.Overview, "overview", unit); AddUnitDetailTabButton(tabs, UnitDetailTab.Upgrade, "upgrade", unit); AddUnitDetailTabButton(tabs, UnitDetailTab.Packages, "packages", unit); AddUnitDetailTabButton(tabs, UnitDetailTab.Cards, "cards", unit); return tabs; } private void AddUnitDetailTabButton(VisualElement tabs, UnitDetailTab tab, string label, DistroDefinition unit) { Button button = new(() => { _unitDetailTab = tab; _packagePickerSlot = null; _packageSlotNotice = null; RenderUnitDetail(unit); ViewChanged?.Invoke(); }) { text = label, focusable = false }; button.AddToClassList("collection-tab"); button.EnableInClassList("selected", _unitDetailTab == tab); tabs.Add(button); } private void AddUnitOverviewTab(VisualElement target, DistroDefinition unit) { Label identityTitle = new("identity"); identityTitle.AddToClassList("detail-section-title"); target.Add(identityTitle); target.Add(BuildDetailLine("lang", DistroPresentation.FormatLanguages(unit))); Label description = new(string.IsNullOrWhiteSpace(unit.Description) ? "--" : unit.Description); description.AddToClassList("collection-detail-description"); target.Add(description); AddPassiveDetails(target, unit); VisualElement readout = new(); readout.AddToClassList("collection-detail-readout"); Label artLabel = new(); DistroArtPresenter.ConfigureArtLabel(artLabel, _monospaceFont); AsciiArtFitter artFitter = new(artLabel, _monospaceFont); VisualElement artPlaceholder = DistroArtPresenter.CreatePlaceholder(); artFitter.SetArt(DistroArtPresenter.Render(artLabel, artPlaceholder, unit)); readout.Add(artPlaceholder); readout.Add(artLabel); VisualElement details = new(); details.AddToClassList("collection-detail-values"); details.Add(BuildDetailLine("lang", DistroPresentation.FormatLanguages(unit))); details.Add(BuildDetailLine("uptime", unit.BaseUptime.ToString())); details.Add(BuildDetailLine("ram", unit.BaseRam.ToString())); details.Add(BuildDetailLine("cycles", unit.BaseCyclesPerTurn.ToString())); readout.Add(details); target.Add(readout); } private void AddUnitUpgradeTab(VisualElement target, DistroDefinition unit) { Label title = new("version upgrade"); title.AddToClassList("detail-section-title"); target.Add(title); AddVersionUpgradePanel(target, unit); } private void AddUnitCardsTab(VisualElement target, DistroDefinition unit) { Label title = new("core package cards"); title.AddToClassList("detail-section-title"); target.Add(title); target.Add(BuildSubviewCommand($"cat ~/units/{unit.Id}/cards", "exclusive packages", HasListableCards(unit), () => OpenUnitCards(unit), "no packages installed")); } private void AddUnitPackagesTab(VisualElement target, DistroDefinition unit) { if (unit == null || _packageLoadout == null || _playerCollection == null) { return; } Label title = new("packages"); title.AddToClassList("detail-section-title"); target.Add(title); _packagePickerSlot ??= PackageSlot.Kernel; VisualElement layout = new(); layout.AddToClassList("packages-summary-layout"); VisualElement slots = new(); slots.AddToClassList("packages-slot-list"); AddPackageSlotSummaryRow(slots, unit, PackageSlot.Kernel); AddPackageSlotSummaryRow(slots, unit, PackageSlot.Runtime); AddPackageSlotSummaryRow(slots, unit, PackageSlot.Daemon); layout.Add(slots); VisualElement impact = new(); impact.AddToClassList("packages-impact-panel"); AddPackageStatImpactPanel(impact, unit); layout.Add(impact); target.Add(layout); } private void AddPackageSlotSummaryRow(VisualElement target, DistroDefinition unit, PackageSlot slot) { PackageDefinition equipped = _packageLoadout.GetEquippedPackage(unit.Id, slot); OwnedPackageInstance owned = equipped == null ? null : _playerCollection.GetOwnedPackage(equipped.Id); VisualElement row = new(); row.AddToClassList("package-slot-row"); row.EnableInClassList("selected", _packagePickerSlot == slot); row.RegisterCallback(_ => { _packagePickerSlot = slot; _packageSlotNotice = null; OpenPackageEquipWindow(unit, slot); }); row.RegisterCallback(evt => { if (evt.keyCode == KeyCode.Return || evt.keyCode == KeyCode.KeypadEnter) { _packagePickerSlot = slot; _packageSlotNotice = null; OpenPackageEquipWindow(unit, slot); evt.StopPropagation(); } }); row.focusable = true; Label slotLabel = new(slot.ToString().ToUpperInvariant()); slotLabel.AddToClassList("package-slot-label"); row.Add(slotLabel); Label name = new(equipped == null ? "[ empty ]" : $"[{PackageName(equipped)}]"); name.AddToClassList("package-slot-name"); if (equipped != null) { name.style.color = new StyleColor(RarityPresentation.ForStars(equipped.Rarity).Color); } row.Add(name); Label rarity = new(equipped == null ? string.Empty : RarityPresentation.ForStars(equipped.Rarity).Badge); rarity.AddToClassList("package-slot-rarity"); if (equipped != null) { rarity.AddToClassList(RarityPresentation.ForStars(equipped.Rarity).ClassName); } row.Add(rarity); Label level = new(owned == null ? string.Empty : $"Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}"); level.AddToClassList("package-slot-level"); row.Add(level); Label effect = new(equipped == null ? string.Empty : OneLineEffect(equipped, owned?.UpgradeLevel ?? 0, unit.Id)); effect.AddToClassList("package-slot-effect"); row.Add(effect); Label action = new("[enter] equip"); action.AddToClassList("package-slot-action"); row.Add(action); target.Add(row); } private void AddPackageStatImpactPanel(VisualElement target, DistroDefinition unit) { Label title = new("stat impact"); title.AddToClassList("detail-section-title"); target.Add(title); IReadOnlyList packages = _packageLoadout.GetEquippedPackageInstances(unit.Id); PackageStatImpact impact = RunManager.CalculatePackageStatImpact(unit, packages); target.Add(BuildStatImpactLine("Uptime", impact.BaseUptime, impact.FinalUptime, impact.UptimeBonus, impact.UptimeSources)); target.Add(BuildStatImpactLine("RAM", impact.BaseRam, impact.FinalRam, impact.RamBonus, impact.RamSources)); target.Add(BuildStatImpactLine("Cycles", impact.BaseCycles, impact.FinalCycles, impact.CyclesBonus, impact.CyclesSources)); VisualElement active = new(); active.AddToClassList("packages-active-effects"); Label activeTitle = new("active effects"); activeTitle.AddToClassList("detail-section-title"); active.Add(activeTitle); int count = 0; AddActivePackageEffect(active, unit, PackageSlot.Kernel, ref count); AddActivePackageEffect(active, unit, PackageSlot.Runtime, ref count); AddActivePackageEffect(active, unit, PackageSlot.Daemon, ref count); if (count == 0) { Label empty = new("none"); empty.AddToClassList("collection-detail-muted"); active.Add(empty); } target.Add(active); } private VisualElement BuildStatImpactLine(string label, int before, int after, int delta, IReadOnlyList sources) { VisualElement row = new(); row.AddToClassList("package-stat-line"); Label key = new(label); key.AddToClassList("package-stat-key"); Label values = new($"{before} -> {after}"); values.AddToClassList("package-stat-value"); Label bonus = new(delta > 0 ? $"(+{delta}{FormatSources(sources)})" : string.Empty); bonus.AddToClassList("package-stat-bonus"); row.Add(key); row.Add(values); row.Add(bonus); return row; } private void AddActivePackageEffect(VisualElement target, DistroDefinition unit, PackageSlot slot, ref int count) { PackageDefinition package = _packageLoadout.GetEquippedPackage(unit.Id, slot); OwnedPackageInstance owned = package == null ? null : _playerCollection.GetOwnedPackage(package.Id); if (package == null || owned == null) { return; } PackageEffectData effect = package.EffectFor(unit.Id); if (package.Slot == PackageSlot.Kernel && IsStatPackageEffect(effect.Kind)) { return; } string summary = OneLineEffect(package, owned.UpgradeLevel, unit.Id); if (string.IsNullOrWhiteSpace(summary)) { return; } Label line = new($"{PackageName(package)}: {summary}"); line.AddToClassList("package-active-effect"); target.Add(line); count++; } private static bool IsStatPackageEffect(PackageEffectKind kind) { return kind == PackageEffectKind.MaxUptime || kind == PackageEffectKind.MaxRam || kind == PackageEffectKind.MaxCycles; } private static string FormatSources(IReadOnlyList sources) { return sources == null || sources.Count == 0 ? string.Empty : $", {string.Join(", ", sources)}"; } private void AddSelectedPackageSlotActions(VisualElement target, DistroDefinition unit, PackageSlot slot, int cache, int bandwidth) { PackageDefinition equipped = _packageLoadout.GetEquippedPackage(unit.Id, slot); OwnedPackageInstance owned = equipped == null ? null : _playerCollection.GetOwnedPackage(equipped.Id); Label title = new($"{slot.ToString().ToLowerInvariant()} actions"); title.AddToClassList("detail-section-title"); target.Add(title); if (!string.IsNullOrWhiteSpace(_packageSlotNotice)) { Label notice = new(_packageSlotNotice); notice.AddToClassList("package-notice"); notice.AddToClassList("package-slot-notice"); target.Add(notice); } if (owned == null || equipped == null) { target.Add(BuildDetailLine("status", "empty slot")); target.Add(BuildDetailLine("equip", "select an owned package below")); return; } target.Add(BuildDetailLine("level", $"Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}")); int nextLevel = owned.UpgradeLevel + 1; bool maxLevel = owned.UpgradeLevel >= PackageTuning.MaxPackageLevel; int cacheCost = maxLevel ? 0 : PackageTuning.GetUpgradeCacheCost(nextLevel, equipped.Rarity); int bandwidthCost = maxLevel ? 0 : PackageTuning.GetUpgradeBandwidthCost(nextLevel, equipped.Rarity); bool canUpgrade = !maxLevel && cache >= cacheCost && bandwidth >= bandwidthCost && _tryUpgradePackage != null; string upgradeState = maxLevel ? "max level" : cache < cacheCost ? "insufficient cache" : bandwidth < bandwidthCost ? "insufficient bandwidth" : "ready"; string currentEffect = FormatPackageEffect(equipped, owned.UpgradeLevel, unit.Id); string nextPreview = maxLevel ? "max level reached" : $"{currentEffect} -> {FormatPackageEffect(equipped, nextLevel, unit.Id)}"; target.Add(BuildPackagePreviewLine("current", currentEffect)); target.Add(BuildPackagePreviewLine("next", nextPreview)); target.Add(BuildCompactUpgradeCommand($"> make upgrade (Cache {cacheCost} / Bandwidth {bandwidthCost})", upgradeState, canUpgrade, () => UpgradePackage(owned))); } private void AddPackagePicker(VisualElement target, DistroDefinition unit, PackageSlot slot) { Label title = new($"select {slot.ToString().ToLowerInvariant()} package"); title.AddToClassList("detail-section-title"); target.Add(title); PackageDefinition equipped = _packageLoadout.GetEquippedPackage(unit.Id, slot); 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); int packageLevel = _playerCollection.GetOwnedPackage(package.Id)?.UpgradeLevel ?? 0; VisualElement row = new(); row.AddToClassList("package-row"); row.AddToClassList("package-picker-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 effect = new(OneLineEffect(package, packageLevel, unit.Id)); effect.AddToClassList("package-picker-effect"); Label meta = new(package.IsIntendedFor(unit.Id) ? $"{package.Rarity}* on" : $"{package.Rarity}*"); meta.AddToClassList("package-meta"); summary.Add(marker); summary.Add(name); summary.Add(effect); summary.Add(meta); row.Add(summary); target.Add(row); } } private bool TogglePackage(DistroDefinition unit, PackageSlot slot, PackageDefinition package, bool isEquipped) { if (unit == null || package == null || _packageLoadout == null) { return false; } PackageLoadoutFailureReason reason; bool changed = isEquipped ? _packageLoadout.TryUnequip(unit.Id, slot, out reason) : _packageLoadout.TryEquip(unit.Id, slot, package.Id, out reason); if (changed) { _packageSlotNotice = null; _packageLoadoutChanged?.Invoke(); } if (!changed) { _packageSlotNotice = FormatPackageFailure(reason); } RenderUnitDetail(unit); return changed; } private void EnsurePackageEquipOverlay() { if (_root == null || _packageEquipOverlay != null) { return; } _packageEquipOverlay = new VisualElement(); _packageEquipOverlay.AddToClassList("modal-overlay"); _packageEquipOverlay.AddToClassList("package-equip-overlay"); _packageEquipOverlay.AddToClassList("hidden"); _packageEquipDialog = new VisualElement(); _packageEquipDialog.AddToClassList("package-equip-dialog"); _packageEquipOverlay.Add(_packageEquipDialog); _root.Add(_packageEquipOverlay); } private void OpenPackageEquipWindow(DistroDefinition unit, PackageSlot slot) { if (unit == null) { return; } EnsurePackageEquipOverlay(); _equipWindowUnit = unit; _equipWindowSlot = slot; _packageSlotNotice = null; RenderPackageEquipWindow(); _packageEquipOverlay.RemoveFromClassList("hidden"); _packageEquipOverlay.AddToClassList("modal-open"); } private void ClosePackageEquipWindow() { _packageEquipOverlay?.RemoveFromClassList("modal-open"); _packageEquipOverlay?.AddToClassList("hidden"); _equipWindowUnit = null; _packageSlotNotice = null; } private void RenderPackageEquipWindow() { if (_packageEquipDialog == null || _equipWindowUnit == null) { return; } _packageEquipDialog.Clear(); PackageDefinition equipped = _packageLoadout.GetEquippedPackage(_equipWindowUnit.Id, _equipWindowSlot); VisualElement header = new(); header.AddToClassList("package-equip-header"); Label title = new($"equip: {_equipWindowSlot.ToString().ToLowerInvariant()} - {DistroPresentation.DisplayName(_equipWindowUnit)}"); title.AddToClassList("collection-detail-name"); title.style.color = new StyleColor(_equipWindowUnit.AccentColor); header.Add(title); Button close = new(ClosePackageEquipWindow) { text = "x", focusable = false }; close.AddToClassList("modal-close-button"); header.Add(close); _packageEquipDialog.Add(header); if (!string.IsNullOrWhiteSpace(_packageSlotNotice)) { Label notice = new(_packageSlotNotice); notice.AddToClassList("package-notice"); _packageEquipDialog.Add(notice); } string equippedName = equipped == null ? "empty" : PackageName(equipped); _packageEquipDialog.Add(BuildDetailLine("current", equippedName)); if (equipped != null) { _packageEquipDialog.Add(BuildCompactUpgradeCommand("> unlink current", "unequip", true, () => UnequipPackageFromWindow(_equipWindowUnit, _equipWindowSlot))); } ScrollView list = new(ScrollViewMode.Vertical); list.AddToClassList("package-equip-list"); int count = 0; for (int i = 0; i < _playerCollection.OwnedPackageInstances.Count; i++) { OwnedPackageInstance owned = _playerCollection.OwnedPackageInstances[i]; PackageDefinition package = owned?.Definition; if (package == null || package.Slot != _equipWindowSlot) { continue; } list.Add(BuildEquipWindowPackageRow(owned, equipped)); count++; } if (count == 0) { Label empty = new($"no owned {_equipWindowSlot.ToString().ToLowerInvariant()} packages"); empty.AddToClassList("package-empty"); list.Add(empty); } _packageEquipDialog.Add(list); _packageEquipDialog.Add(BuildCompactUpgradeCommand("> cancel", "back", true, ClosePackageEquipWindow)); } private VisualElement BuildEquipWindowPackageRow(OwnedPackageInstance owned, PackageDefinition equipped) { PackageDefinition package = owned.Definition; bool isEquippedHere = equipped != null && string.Equals(equipped.Id, package.Id, StringComparison.OrdinalIgnoreCase); VisualElement row = new(); row.AddToClassList("package-row"); row.AddToClassList("package-equip-row"); row.EnableInClassList("equipped", isEquippedHere); row.EnableInClassList($"rarity-{package.Rarity}", true); row.EnableInClassList("on-distro", package.IsIntendedFor(_equipWindowUnit.Id)); row.RegisterCallback(_ => EquipPackageFromWindow(_equipWindowUnit, _equipWindowSlot, package, isEquippedHere)); VisualElement summary = new(); summary.AddToClassList("package-summary"); Label marker = new(isEquippedHere ? "[x]" : "[ ]"); marker.AddToClassList("package-marker"); Label name = new(PackageName(package)); name.AddToClassList("package-name"); Label meta = new($"{RarityPresentation.ForStars(package.Rarity).Badge} Lv {owned.UpgradeLevel}/{PackageTuning.MaxPackageLevel}"); meta.AddToClassList("package-meta"); summary.Add(marker); summary.Add(name); summary.Add(meta); row.Add(summary); Label effect = new(FormatPackageEffect(package, owned.UpgradeLevel, _equipWindowUnit.Id)); effect.AddToClassList("package-description"); row.Add(effect); VisualElement tags = new(); tags.AddToClassList("package-equip-tags"); string equippedOn = FindEquippedDistroName(package.Id); if (!string.IsNullOrWhiteSpace(equippedOn)) { Label equippedTag = new($"equipped on {equippedOn}"); equippedTag.AddToClassList("package-notice"); tags.Add(equippedTag); } if (package.IsIntendedFor(_equipWindowUnit.Id)) { Label intended = new("on-distro"); intended.AddToClassList("package-notice"); tags.Add(intended); } if (tags.childCount > 0) { row.Add(tags); } return row; } private void EquipPackageFromWindow(DistroDefinition unit, PackageSlot slot, PackageDefinition package, bool isEquipped) { if (isEquipped) { UnequipPackageFromWindow(unit, slot); return; } if (EquipOrSwapPackage(unit, slot, package)) { ClosePackageEquipWindow(); RenderUnitDetail(unit); ViewChanged?.Invoke(); return; } RenderPackageEquipWindow(); } private bool EquipOrSwapPackage(DistroDefinition unit, PackageSlot slot, PackageDefinition package) { if (unit == null || package == null || _packageLoadout == null) { return false; } PackageDefinition currentlyEquipped = _packageLoadout.GetEquippedPackage(unit.Id, slot); if (currentlyEquipped != null && !string.Equals(currentlyEquipped.Id, package.Id, StringComparison.OrdinalIgnoreCase)) { if (!_packageLoadout.TryUnequip(unit.Id, slot, out PackageLoadoutFailureReason unequipReason)) { _packageSlotNotice = FormatPackageFailure(unequipReason); return false; } } if (!_packageLoadout.TryEquip(unit.Id, slot, package.Id, out PackageLoadoutFailureReason equipReason)) { _packageSlotNotice = FormatPackageFailure(equipReason); return false; } _packageSlotNotice = null; _packageLoadoutChanged?.Invoke(); return true; } private void UnequipPackageFromWindow(DistroDefinition unit, PackageSlot slot) { PackageDefinition equipped = _packageLoadout.GetEquippedPackage(unit.Id, slot); if (equipped == null) { return; } if (TogglePackage(unit, slot, equipped, true)) { ClosePackageEquipWindow(); RenderUnitDetail(unit); ViewChanged?.Invoke(); return; } RenderPackageEquipWindow(); } private void AddVersionUpgradePanel(VisualElement target, DistroDefinition unit) { int version = _playerCollection == null ? 1 : Mathf.Clamp(_playerCollection.GetVersion(unit.Id), 1, GachaTuning.MaxVersion); string release = DistroVersionCatalog.GetReleaseLabel(unit.Id, version); int merges = _getMergesBalance?.Invoke(unit) ?? 0; VisualElement panel = new(); panel.AddToClassList("version-upgrade-panel"); VisualElement summary = new(); summary.AddToClassList("version-upgrade-summary"); Label current = new($"{DistroPresentation.DisplayName(unit)} {release} v{version}/{GachaTuning.MaxVersion}"); current.AddToClassList("version-upgrade-current"); summary.Add(current); Label wallet = new(version < GachaTuning.MaxVersion ? $"{unit.DisplayName} merges {merges} / {GachaTuning.GetVersionUpgradeCost(version + 1)}" : $"{unit.DisplayName} merges {merges}"); wallet.AddToClassList("version-upgrade-wallet"); summary.Add(wallet); panel.Add(summary); if (version < GachaTuning.MaxVersion) { int targetVersion = version + 1; int cost = GachaTuning.GetVersionUpgradeCost(targetVersion); Label next = new($"next {DistroVersionCatalog.GetReleaseLabel(unit.Id, targetVersion)} - {DistroVersionCatalog.GetEffectSummary(unit.Id, targetVersion)}"); next.AddToClassList("version-upgrade-next"); panel.Add(next); bool affordable = merges >= cost; wallet.EnableInClassList(affordable ? "version-upgrade-wallet-affordable" : "version-upgrade-wallet-warning", true); panel.Add(BuildCompactUpgradeCommand($"> apt full-upgrade ({cost} merges)", affordable ? "ready" : "insufficient merges", affordable && _tryUpgradeUnit != null, () => UpgradeUnit(unit))); } else { Label next = new("latest release installed"); next.AddToClassList("version-upgrade-next"); panel.Add(next); panel.Add(BuildCompactUpgradeCommand("> apt full-upgrade", "latest release", false, null)); } VisualElement path = new(); path.AddToClassList("version-roadmap"); for (int i = 1; i <= GachaTuning.MaxVersion; i++) { bool owned = i <= version; bool next = i == version + 1; int cost = i <= 1 ? 0 : GachaTuning.GetVersionUpgradeCost(i); string className = owned ? "version-path-owned" : next ? "version-path-next" : "version-path-locked"; string costText = owned || i <= 1 ? string.Empty : $" ({cost} merges)"; Label step = new($"v{i} {DistroVersionCatalog.GetReleaseLabel(unit.Id, i)} - {DistroVersionCatalog.GetEffectSummary(unit.Id, i)}{costText}"); step.AddToClassList("version-roadmap-step"); step.AddToClassList(className); path.Add(step); } panel.Add(path); target.Add(panel); } private void UpgradeUnit(DistroDefinition unit) { if (unit == null || _tryUpgradeUnit == null) { return; } VersionUpgradeResult result = _tryUpgradeUnit(unit); RenderUnitDetail(unit); if (!result.Success) { _detail.Add(BuildDetailLine("upgrade", FormatUpgradeFailure(result.FailureReason))); } } private void UpgradePackage(OwnedPackageInstance package) { bool inventoryMode = _mode == CollectionMode.Packages; PackageUpgradeResult result = _tryUpgradePackage == null ? new PackageUpgradeResult(false, PackageUpgradeFailureReason.NotOwned, 0, 0, 0) : _tryUpgradePackage(package); RefreshAfterPackageAction(inventoryMode); if (!result.Success) { _detail.Add(BuildDetailLine("package upgrade", FormatPackageUpgradeFailure(result.FailureReason))); } } private void ScrapPackage(OwnedPackageInstance package) { if (package == null) { return; } bool inventoryMode = _mode == CollectionMode.Packages; if (!string.Equals(_pendingScrapPackageId, package.PackageId, StringComparison.OrdinalIgnoreCase)) { _pendingScrapPackageId = package.PackageId; RefreshAfterPackageAction(inventoryMode); return; } PackageScrapResult result = _tryScrapPackage == null ? new PackageScrapResult(false, PackageScrapFailureReason.NotOwned, 0, 0) : _tryScrapPackage(package); _pendingScrapPackageId = null; 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(); bool unlocked = LanguageUnlock.IsUnlocked(language.Language, _playerCollection); Label name = new(language.DisplayName); name.AddToClassList("collection-detail-name"); _detail.Add(name); _detail.Add(BuildDetailLine("track", language.ResolutionTrack.ToString())); Label how = new(language.HowItWorks); how.AddToClassList("collection-detail-description"); _detail.Add(how); if (!unlocked) { string hintText = string.IsNullOrWhiteSpace(language.UnlockHint) ? $"unlock: own a distro that speaks {language.DisplayName} ({FormatSupportingDistros(language)})" : language.UnlockHint; Label hint = new(hintText); hint.AddToClassList("package-notice"); _detail.Add(hint); return; } _detail.Add(BuildSubviewCommand($"cat ~/lang/{GetLanguageId(language.Language)}/deck", "starter deck", true, () => OpenLanguageDeck(language), null)); } private void OpenUnitCards(DistroDefinition unit) { if (!HasListableCards(unit)) { return; } _returnMode = _mode == CollectionMode.UnitDetail ? CollectionMode.UnitDetail : CollectionMode.Units; _cards = BuildUnitCardEntries(unit); _emptyCardMessage = "no packages installed"; CurrentTitle = $"$ cat ~/units/{unit.Id}/cards"; CurrentHint = "[esc] back [arrows] navigate"; RenderCardSubview(); ViewChanged?.Invoke(); } private void OpenLanguageDeck(LanguageCatalogEntry language) { _returnMode = CollectionMode.Languages; _cards = BuildStarterDeckEntries(language); _emptyCardMessage = "starter deck not yet available"; CurrentTitle = $"$ cat ~/lang/{GetLanguageId(language.Language)}/deck"; CurrentHint = "[esc] back [arrows] navigate"; RenderCardSubview(); ViewChanged?.Invoke(); } private void RenderCardSubview() { _mode = CollectionMode.CardSubview; _rows.Clear(); _list.Clear(); _detail.Clear(); _list.RemoveFromClassList("hidden"); _detailScroll?.RemoveFromClassList("collection-detail-full"); if (_detailScroll != null) { _detailScroll.verticalScrollerVisibility = ScrollerVisibility.Auto; } _selectedCardIndex = Mathf.Clamp(_selectedCardIndex, 0, Math.Max(0, _cards.Count - 1)); if (_cards.Count == 0) { _detail.Add(new Label(_emptyCardMessage) { name = "CollectionEmptyTitle" }); _detail.Add(new Label("check back after deck data is installed") { name = "CollectionEmptyHint" }); return; } for (int i = 0; i < _cards.Count; i++) { int index = i; CardEntry entry = _cards[i]; VisualElement row = BuildCardRow(entry); row.RegisterCallback(_ => SelectCard(index)); row.RegisterCallback(_ => SelectCard(index)); _list.Add(row); _rows.Add(row); } SelectCard(_selectedCardIndex); } private VisualElement BuildCardRow(CardEntry entry) { VisualElement row = new(); row.AddToClassList("package-row"); row.AddToClassList("collection-card-row"); VisualElement summary = new(); summary.AddToClassList("package-summary"); Label count = new(entry.Count > 1 ? $"x{entry.Count}" : string.Empty); count.AddToClassList("package-marker"); Label name = new(GetCardDisplayName(entry.Card)); name.AddToClassList("package-name"); Label meta = new(FormatCardMeta(entry.Card)); meta.AddToClassList("package-meta"); summary.Add(count); summary.Add(name); summary.Add(meta); row.Add(summary); Label description = new(entry.Card == null || string.IsNullOrWhiteSpace(entry.Card.Description) ? "--" : entry.Card.Description); description.AddToClassList("package-description"); row.Add(description); return row; } private void RenderCardDetail(CardEntry entry) { _detail.Clear(); CardDefinition card = entry.Card; Label name = new(GetCardDisplayName(card)); name.AddToClassList("collection-detail-name"); _detail.Add(name); Label description = new(card == null || string.IsNullOrWhiteSpace(card.Description) ? "--" : card.Description); description.AddToClassList("collection-detail-description"); _detail.Add(description); if (card != null && !string.IsNullOrWhiteSpace(card.FlavorText)) { Label flavor = new(card.FlavorText); flavor.AddToClassList("flavor-text"); _detail.Add(flavor); } _detail.Add(BuildDetailLine("lang", card == null ? "--" : card.Language.ToString())); _detail.Add(BuildDetailLine("rarity", card == null ? "--" : card.Rarity.ToString())); _detail.Add(BuildDetailLine("cost", card == null ? "--" : card.CycleCost.ToString())); _detail.Add(BuildDetailLine("track", card == null ? "--" : card.ResolutionTrack.ToString())); } private static VisualElement BuildDetailLine(string key, string value) { VisualElement row = new(); row.AddToClassList("kv-row"); Label keyLabel = new(key); keyLabel.AddToClassList("kv-key"); Label valueLabel = new(value); valueLabel.AddToClassList("kv-value"); row.Add(keyLabel); row.Add(valueLabel); return row; } private static VisualElement BuildPackagePreviewLine(string key, string value) { VisualElement row = new(); row.AddToClassList("package-preview-line"); Label keyLabel = new(key); keyLabel.AddToClassList("package-preview-key"); Label valueLabel = new(value); valueLabel.AddToClassList("package-preview-value"); row.Add(keyLabel); row.Add(valueLabel); return row; } private VisualElement BuildSubviewCommand(string command, string description, bool enabled, Action action, string disabledDescription) { VisualElement row = new(); row.AddToClassList("command-row"); row.AddToClassList("collection-command-row"); row.EnableInClassList("disabled-row", !enabled); Label cursor = new(">"); cursor.AddToClassList("command-cursor"); Label commandLabel = new(command); commandLabel.AddToClassList("command-text"); Label descriptionLabel = new(enabled ? description : disabledDescription); descriptionLabel.AddToClassList("command-description"); row.Add(cursor); row.Add(commandLabel); row.Add(descriptionLabel); if (enabled) { row.RegisterCallback(_ => action?.Invoke()); } return row; } private static VisualElement BuildCompactUpgradeCommand(string command, string state, bool enabled, Action action) { VisualElement row = new(); row.AddToClassList("version-upgrade-command"); row.EnableInClassList("disabled-row", !enabled); Label commandLabel = new(command); commandLabel.AddToClassList("version-upgrade-command-text"); Label stateLabel = new(state); stateLabel.AddToClassList("version-upgrade-command-state"); row.Add(commandLabel); row.Add(stateLabel); if (enabled) { row.RegisterCallback(_ => action?.Invoke()); } return row; } private static void AddPassiveDetails(VisualElement target, DistroDefinition unit) { if (unit.Passive == null) { return; } Label passiveTitle = new(unit.Passive.Name); passiveTitle.AddToClassList("detail-section-title"); target.Add(passiveTitle); Label passiveRules = new(unit.Passive.RulesText); passiveRules.AddToClassList("collection-detail-description"); target.Add(passiveRules); if (!string.IsNullOrWhiteSpace(unit.Passive.FlavorText)) { Label passiveFlavor = new(unit.Passive.FlavorText); passiveFlavor.AddToClassList("flavor-text"); target.Add(passiveFlavor); } } private static IReadOnlyList BuildUnitCardEntries(DistroDefinition unit) { List entries = new(); if (unit == null) { return entries; } for (int cardIndex = 0; cardIndex < unit.ExclusiveCards.Count; cardIndex++) { CardDefinition card = unit.ExclusiveCards[cardIndex]; if (card == null || card.IsToken || card.IsRunOnly) { continue; } entries.Add(new CardEntry(unit, card, 1)); } return entries; } private IReadOnlyList BuildStarterDeckEntries(LanguageCatalogEntry language) { LanguageDeckDefinition deck = _languageDeckDatabase == null ? null : _languageDeckDatabase.FindByLanguage(language.Language); if (deck == null) { return BuildRegisteredStarterDeckEntries(language.Language); } List entries = new(); for (int i = 0; i < deck.Entries.Count; i++) { LanguageDeckDefinition.LanguageDeckEntry deckEntry = deck.Entries[i]; if (deckEntry.Card == null || deckEntry.Card.IsRunOnly || deckEntry.Count <= 0) { continue; } entries.Add(new CardEntry(null, deckEntry.Card, deckEntry.Count)); } return entries; } private IReadOnlyList BuildRegisteredStarterDeckEntries(Language language) { if (_cardDatabase == null) { return Array.Empty(); } return language switch { Language.Python => BuildRegisteredStarterDeckEntries( ("lang_py_print", 2), ("lang_py_import_antigravity", 1), ("lang_py_for_loop", 1)), Language.JavaScript => BuildRegisteredStarterDeckEntries( ("lang_js_console_log", 2), ("lang_js_fetch", 1), ("lang_js_typeof", 1)), _ => Array.Empty() }; } private IReadOnlyList BuildRegisteredStarterDeckEntries(params (string Id, int Count)[] cardRefs) { List entries = new(); for (int i = 0; i < cardRefs.Length; i++) { CardDefinition card = _cardDatabase.FindById(cardRefs[i].Id); if (card == null || card.IsRunOnly) { continue; } entries.Add(new CardEntry(null, card, cardRefs[i].Count)); } return entries; } private static bool HasListableCards(DistroDefinition unit) { return BuildUnitCardEntries(unit).Count > 0; } private string FormatSupportingDistros(LanguageCatalogEntry language) { List names = new(); for (int i = 0; i < language.SupportingDistros.Count; i++) { names.Add(language.SupportingDistros[i].DisplayName); } return string.Join(", ", names); } private static string GetCardDisplayName(CardDefinition card) { if (card == null) { return "--"; } return string.IsNullOrWhiteSpace(card.DisplayName) ? card.Id : card.DisplayName; } private static string FormatCardMeta(CardDefinition card) { return card == null ? "--" : $"{card.Language} / {card.CycleCost}c"; } private static string PackageName(PackageDefinition package) { return package == null ? "--" : string.IsNullOrWhiteSpace(package.DisplayName) ? package.Id : package.DisplayName; } private static string FormatPackageEffect(PackageDefinition package, int upgradeLevel, string distroId) { if (package == null) { return "--"; } string summary = FormatEffectSummary(package.EffectFor(distroId), upgradeLevel); if (!string.IsNullOrWhiteSpace(summary)) { return summary; } return string.IsNullOrWhiteSpace(package.Description) ? "--" : package.Description; } private static string OneLineEffect(PackageDefinition package, int upgradeLevel, string distroId) { if (package == null) { return string.Empty; } string text = FormatEffectSummary(package.EffectFor(distroId), upgradeLevel); if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(package.Description)) { text = package.Description.Trim(); } int stop = text.IndexOf('.'); if (stop >= 0) { text = text.Substring(0, stop + 1); } return text.Length > 54 ? $"{text.Substring(0, 51)}..." : text; } private static string FormatEffectSummary(PackageEffectData baseEffect, int upgradeLevel) { PackageEffectData effect = PackageEffectScaling.Scale(baseEffect, upgradeLevel); int amount = Math.Max(0, effect.Amount); return effect.Kind switch { PackageEffectKind.None => string.Empty, PackageEffectKind.MaxUptime => $"+{amount} max Uptime", PackageEffectKind.MaxCycles => $"+{amount} max Cycles", PackageEffectKind.MaxRam => $"+{amount} max RAM", PackageEffectKind.WaveStartShield => $"+{amount} Shield at wave start", PackageEffectKind.FirstTurnEachWaveShield => $"+{amount} Shield on first turn", PackageEffectKind.FirstTurnFirstWaveDraw => $"draw {amount} on first turn", PackageEffectKind.WaveDraw => $"draw {amount} at wave start", PackageEffectKind.FirstTurnEachWaveCycle => $"+{amount} Cycle on first turn", PackageEffectKind.WaveGenerateBasicCard => $"generate {amount} basic card(s) at wave start", PackageEffectKind.FirstCardEachWaveCostReduction => $"first card each wave costs {amount} less", PackageEffectKind.FirstCardsEachTurnCostReduction => $"first card each turn costs {amount} less", PackageEffectKind.ThirdCardEachTurnGenerate => $"third card each turn generates {amount} card(s)", PackageEffectKind.FirstNativeCardEachWaveFlatDamage => $"first Native card +{amount} damage", PackageEffectKind.ExhaustShield => $"+{amount} Shield when exhausting", PackageEffectKind.EveryNthTurnShield => $"+{amount} Shield every {effect.Threshold} turns", PackageEffectKind.EveryNthTurnDraw => $"draw {amount} every {effect.Threshold} turns", PackageEffectKind.FirstInterpreterQueueCardEachWaveShield => $"+{amount} Shield on queued card", PackageEffectKind.FirstShieldEachTurnBonus => $"+{amount} extra Shield once/turn", PackageEffectKind.EveryNthCardEachWaveFree => $"every {effect.Threshold}th card costs 0", PackageEffectKind.EveryNthCardEachWaveCycle => $"+{amount} Cycle every {effect.Threshold} cards", PackageEffectKind.StartTurnNoDebuffShield => $"+{amount} Shield if no debuffs", PackageEffectKind.JavaScriptFlatDamage => $"+{amount} JavaScript damage", PackageEffectKind.WaveThresholdRestore => $"restore {amount} Uptime after wave {effect.Threshold}", PackageEffectKind.DnfFedoraPassive => "Fedora passive can trigger twice", PackageEffectKind.FirstCThisTurnDamageMultiplier => $"first C card each turn deals {amount}% damage", PackageEffectKind.TurnStartGenerateLanguageCard => amount > 0 ? "turn start: generate C/Rust card at -1c" : "turn start: generate common C/Rust card", _ => amount > 0 ? $"+{amount} package effect" : "package effect" }; } private string GetUnitDetailHint() { string action = _unitDetailTab switch { UnitDetailTab.Upgrade => "[enter/click] upgrade", UnitDetailTab.Packages => "[enter/click] select/equip", UnitDetailTab.Cards => "[enter] open cards", _ => "[read-only]" }; return $"[esc] back [left/right/tab] tabs {action}"; } private static string FormatPackageFailure(PackageLoadoutFailureReason reason) { return reason switch { PackageLoadoutFailureReason.WrongSlot => "wrong slot", PackageLoadoutFailureReason.NotOwned => "package not owned", PackageLoadoutFailureReason.SlotOccupied => "slot occupied: unequip current package first", PackageLoadoutFailureReason.NotEquipped => "package not equipped", _ => "package change failed" }; } private static string FormatUpgradeFailure(VersionUpgradeFailureReason reason) { return reason switch { VersionUpgradeFailureReason.NotOwned => "not owned", VersionUpgradeFailureReason.MaxVersion => "latest release", VersionUpgradeFailureReason.InsufficientMerges => "insufficient merges", _ => "upgrade failed" }; } private static string FormatPackageUpgradeFailure(PackageUpgradeFailureReason reason) { return reason switch { PackageUpgradeFailureReason.MaxLevel => "max level", PackageUpgradeFailureReason.InsufficientCache => "insufficient cache", PackageUpgradeFailureReason.InsufficientBandwidth => "insufficient bandwidth", PackageUpgradeFailureReason.NotOwned => "not owned", _ => "upgrade failed" }; } private static string FormatPackageScrapFailure(PackageScrapFailureReason reason) { return reason switch { PackageScrapFailureReason.Equipped => "equipped: unequip first", PackageScrapFailureReason.NotOwned => "not owned", _ => "scrap failed" }; } private static string GetLanguageId(Language language) { return language switch { Language.CPlusPlus => "cpp", Language.JavaScript => "javascript", Language.TypeScript => "typescript", _ => language.ToString().ToLowerInvariant() }; } private enum CollectionMode { Units, Languages, Packages, UnitDetail, CardSubview } private enum UnitDetailTab { Overview, Upgrade, Packages, Cards } private readonly struct CardEntry { public CardEntry(DistroDefinition owner, CardDefinition card, int count) { Owner = owner; Card = card; Count = count; } public DistroDefinition Owner { get; } public CardDefinition Card { get; } public int Count { get; } } } }