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