Restore event and mini hubs, current shop and hunt calendars, and regular seasons; preserve all published windows. Implement story/quiz routes, native shop purchases, avatar sets, currencies, rank-change items and permanent buffs with rollback and reward coverage tests. Refresh client buff stats and update Cash Shop to 0.1.1.
639 lines
32 KiB
C#
639 lines
32 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Net;
|
|
using System.Reflection;
|
|
using System.Threading.Tasks;
|
|
using BD2.GameNames;
|
|
using BepInEx;
|
|
using HarmonyLib;
|
|
using Newtonsoft.Json;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using gamfs.Platform;
|
|
using gamfs;
|
|
using BDNetwork;
|
|
using Proto.Net;
|
|
using Proto.Design.common;
|
|
using Google.Protobuf;
|
|
|
|
namespace Bd2CashShop;
|
|
|
|
[BepInPlugin("bd2.cashshop", "BD2 Cash Shop", Bd2Build.Versions.Plugin)]
|
|
[BepInDependency("bd2.localidentity")]
|
|
public sealed class Plugin : BaseUnityPlugin
|
|
{
|
|
private static Harmony harmony;
|
|
private static CommerceHost host;
|
|
private static bool applicationQuitting;
|
|
private static Dictionary<string, CommerceProduct> products;
|
|
private static string catalogOrigin;
|
|
private static readonly Dictionary<UnityEngine.Object, Action> priceViews = new Dictionary<UnityEngine.Object, Action>();
|
|
private static readonly HashSet<string> attendanceReceipts = new HashSet<string>(StringComparer.Ordinal);
|
|
private static readonly HashSet<long> attendanceRefreshPayments = new HashSet<long>();
|
|
private sealed class PriceLabelState
|
|
{
|
|
internal string Text;
|
|
internal bool Active;
|
|
internal TextAlignmentOptions Alignment;
|
|
internal bool Wrap;
|
|
internal Vector2 AnchorMin, AnchorMax, Pivot, OffsetMin, OffsetMax;
|
|
internal bool Layout;
|
|
internal Dictionary<Behaviour, bool> Drivers;
|
|
}
|
|
private static readonly Dictionary<CurrencyButton, Dictionary<TMP_Text, PriceLabelState>> unavailablePriceLabels = new Dictionary<CurrencyButton, Dictionary<TMP_Text, PriceLabelState>>();
|
|
private static BepInEx.Logging.ManualLogSource Log;
|
|
private const string Unavailable = "当前服务器未开通购买功能";
|
|
private const string Marker = "<link=bd2-commerce:";
|
|
private static long paySequence = DateTime.UtcNow.Ticks;
|
|
private static readonly BindingFlags All = BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static;
|
|
|
|
private void Awake()
|
|
{
|
|
try
|
|
{
|
|
Log = Logger;
|
|
Game.Validate(typeof(Plugin).Assembly, Bd2Build.Versions.Game, message => Logger.LogInfo(message));
|
|
harmony = new Harmony("bd2.cashshop");
|
|
Patch(typeof(PlatformManager), "Purchase", nameof(Purchase), false, 3);
|
|
Patch(typeof(PlatformManager), "FinishPurchase", nameof(Suppress), false, 2);
|
|
Patch(typeof(PlatformManager), "RetryUnfinishedPurchase", nameof(Suppress), false, 0);
|
|
Patch(typeof(PlatformManager), "SendInAppEventPurchase", nameof(Suppress), false, 4);
|
|
Patch(typeof(CashShopPacket), "SendInAppEventPurchase", nameof(Suppress), false, 2);
|
|
Patch(typeof(CashShopPacket), "AddSubscribeAttendanceInfo", nameof(Suppress), false, 1);
|
|
Patch(typeof(CashShopPacket), "SendPlatformCashShopBuyRequest", nameof(BeforeRequest), false, 10);
|
|
Patch(typeof(PlatformManager), "GetProductMarketPriceWithSymbol", nameof(PriceLabel), false, 1);
|
|
Patch(typeof(PlatformManager), "GetProductMarketPriceOnly", nameof(PriceNumber), false, 1);
|
|
Patch(typeof(PlatformManager), "GetProductCurrencyCode", nameof(CurrencyCode), false, 1);
|
|
Patch(typeof(PlatformRuler), "GetProductAsync", nameof(ProductPrefetch), false, 6);
|
|
Patch(typeof(PlatformManager), "GetPriceLocalization", nameof(LocalPriceLocalization), false, 4);
|
|
Patch(typeof(PlatformRuler), "InitializeGPG", nameof(SkipOfficialBilling), false, 1);
|
|
Patch(typeof(PlatformRuler), "CheckGPGOAuthStatus", nameof(LocalBillingStatus), false, 1);
|
|
Patch(typeof(PlatformRuler), "OnGetPriceLocalizationBySplit", nameof(LocalSplitPrices), false, 4);
|
|
Patch(typeof(CurrencyButton), "SetCash", nameof(SetCash), false, 1);
|
|
Patch(typeof(CashShopBuyPopupUI), "SetProductInfo", nameof(PopupPrice), true, 8);
|
|
MethodInfo paymentPopup = typeof(UIManager).GetGameMethod("OpenSelectiveMessagePopupUI", All, null,
|
|
new[] { typeof(Action<MessagePopupUI>), typeof(string), typeof(string), typeof(string),
|
|
typeof(Action<bool, MessagePopupUI>), typeof(bool), typeof(bool), typeof(MessagePopupUI.EMessagePopupButtonStyle) }, null);
|
|
if (paymentPopup == null) throw new MissingMethodException("UIManager payment confirmation overload");
|
|
harmony.Patch(paymentPopup, prefix: new HarmonyMethod(typeof(Plugin), nameof(SuppressExternalPaymentConfirmation)));
|
|
Patch(typeof(PackagePurchaseButton), "SetPrice", nameof(PackagePrice), true, 0);
|
|
Patch(typeof(PassRootUI), "CheckIsInAppBuyButton", nameof(PassPrice), true, 0);
|
|
Patch(typeof(BuyConfirmPopupUI), "SetPassBuyPopup", nameof(PassPopupPrice), true, 5);
|
|
Patch(typeof(GachaProductButtonElement), "Set", nameof(GachaPrice), true, 6);
|
|
PatchGeneratedReceiver("<SendLoginPassRequest>b__0", nameof(LoginPassRewards));
|
|
PatchGeneratedReceiver("<SendEventRewardRequest>b__0", nameof(EventRewards));
|
|
PassRewardPresentation.Install(harmony);
|
|
RewardBuffRefresh.Install(harmony);
|
|
MethodInfo attendance = typeof(EventPacket).GetGameMethod("RecvAttendanceResponse", All, null,
|
|
new[] { typeof(IMessage), typeof(int), typeof(int) }, null);
|
|
if (attendance == null) throw new MissingMethodException("EventPacket.RecvAttendanceResponse(IMessage,int,int)");
|
|
harmony.Patch(attendance, postfix: new HarmonyMethod(typeof(Plugin), nameof(AttendanceRewards)));
|
|
Patch(typeof(IntroUI), "Awake", nameof(IntroAwake), true, 0);
|
|
EnsureCommerceHost();
|
|
Logger.LogInfo("Cash shop runtime installed: persistent catalog host and server-owned billing hooks");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
products = null;
|
|
Logger.LogError("Cash shop initialization failed: " + exception);
|
|
}
|
|
}
|
|
|
|
private void Patch(Type type, string name, string callback, bool postfix, int count)
|
|
{
|
|
MethodInfo target = type.GetMethods(All).Single(method => method.IsGameMethod(name) && method.GetParameters().Length == count);
|
|
var patch = new HarmonyMethod(typeof(Plugin), callback);
|
|
harmony.Patch(target, prefix: postfix ? null : patch, postfix: postfix ? patch : null);
|
|
}
|
|
|
|
private void PatchGeneratedReceiver(string readableName, string callback)
|
|
{
|
|
// Compiler-generated declaring types are found by their mapped methods;
|
|
// neither an obfuscated type name nor a generated class number is pinned.
|
|
MethodInfo target = typeof(EventPacket).GetNestedTypes(All)
|
|
.SelectMany(type => type.GetMethods(All | BindingFlags.DeclaredOnly))
|
|
.Single(method => method.IsGameMethod(readableName) && method.ReturnType == typeof(bool) &&
|
|
method.GetParameters().Select(parameter => parameter.ParameterType)
|
|
.SequenceEqual(new[] { typeof(byte[]), typeof(int), typeof(int) }));
|
|
harmony.Patch(target, postfix: new HarmonyMethod(typeof(Plugin), callback));
|
|
}
|
|
|
|
private static string Origin()
|
|
{
|
|
var uri = typeof(ServerURLInfo).GetGameField(nameof(ServerURLInfo.MaintenanceUri), All).GetValue(null) as Uri;
|
|
if (uri == null || uri.UserInfo.Length != 0 || uri.Scheme != "http" && uri.Scheme != "https")
|
|
throw new InvalidOperationException("Commerce server origin unavailable");
|
|
return uri.GetLeftPart(UriPartial.Authority);
|
|
}
|
|
|
|
private static void IntroAwake() => EnsureCommerceHost();
|
|
|
|
private static void EnsureCommerceHost()
|
|
{
|
|
if (applicationQuitting || host != null && host.gameObject.activeInHierarchy) return;
|
|
if (host != null) UnityEngine.Object.Destroy(host.gameObject);
|
|
var owner = new GameObject("BD2 Cash Shop Host");
|
|
UnityEngine.Object.DontDestroyOnLoad(owner);
|
|
host = owner.AddComponent<CommerceHost>();
|
|
Log?.LogInfo("Commerce runtime host created: reason=startup-or-intro-restoration");
|
|
host.StartCoroutine(RefreshCatalog());
|
|
}
|
|
|
|
public sealed class CommerceHost : MonoBehaviour
|
|
{
|
|
private void OnApplicationQuit() { applicationQuitting = true; }
|
|
private void OnDestroy()
|
|
{
|
|
if (ReferenceEquals(host, this)) host = null;
|
|
Log?.LogInfo("Commerce runtime host stopped: reason=" + (applicationQuitting ? "application-quit" : "scene-destruction-awaiting-intro-restoration"));
|
|
}
|
|
}
|
|
|
|
private static IEnumerator RefreshCatalog()
|
|
{
|
|
float nextFetch = 0;
|
|
while (true)
|
|
{
|
|
string origin = null;
|
|
try { origin = Origin(); } catch { }
|
|
if (origin != catalogOrigin) { products = null; catalogOrigin = origin; nextFetch = 0; RefreshPrices(); }
|
|
if (origin != null && Time.realtimeSinceStartup >= nextFetch)
|
|
{
|
|
Task<Dictionary<string, CommerceProduct>> request = Task.Run(() => Fetch(origin));
|
|
while (!request.IsCompleted)
|
|
{
|
|
string current = null;
|
|
try { current = Origin(); } catch { }
|
|
if (current != origin) { products = null; }
|
|
yield return null;
|
|
}
|
|
string after = null;
|
|
try { after = Origin(); } catch { }
|
|
if (request.IsFaulted) { products = null; Log?.LogWarning("Commerce catalog unavailable: " + request.Exception.GetBaseException().Message); }
|
|
else if (after == origin) { products = request.Result; Log?.LogInfo("Loaded commerce policy for " + products.Count + " SKUs"); }
|
|
RefreshPrices();
|
|
nextFetch = Time.realtimeSinceStartup + (products == null ? 3 : 30);
|
|
}
|
|
yield return new WaitForSecondsRealtime(1);
|
|
}
|
|
}
|
|
|
|
private static Dictionary<string, CommerceProduct> Fetch(string origin)
|
|
{
|
|
var request = (HttpWebRequest)WebRequest.Create(origin + "/client/commerce");
|
|
request.Method = "GET";
|
|
request.Timeout = 10000;
|
|
request.ReadWriteTimeout = 10000;
|
|
request.AllowAutoRedirect = false;
|
|
string proxy = Environment.GetEnvironmentVariable("BD2_CLIENT_PROXY_URL");
|
|
if (new Uri(origin).IsLoopback || string.IsNullOrWhiteSpace(proxy)) request.Proxy = null;
|
|
else
|
|
{
|
|
if (!Uri.TryCreate(proxy, UriKind.Absolute, out var proxyUri) || proxyUri.Scheme != "http" || proxyUri.UserInfo.Length != 0 || proxyUri.AbsolutePath != "/" || proxyUri.Query.Length != 0 || proxyUri.Fragment.Length != 0)
|
|
throw new InvalidOperationException("Unsupported client commerce proxy");
|
|
request.Proxy = new WebProxy(proxyUri);
|
|
}
|
|
using (var response = (HttpWebResponse)request.GetResponse())
|
|
using (var stream = response.GetResponseStream())
|
|
using (var reader = new StreamReader(stream))
|
|
{
|
|
if (response.StatusCode != HttpStatusCode.OK) throw new IOException("Commerce HTTP status " + response.StatusCode);
|
|
var bodyBuilder = new System.Text.StringBuilder();
|
|
var buffer = new char[8192];
|
|
int read;
|
|
while ((read = reader.Read(buffer, 0, buffer.Length)) != 0)
|
|
{
|
|
if (bodyBuilder.Length + read > 4 * 1024 * 1024) throw new IOException("Commerce catalog too large");
|
|
bodyBuilder.Append(buffer, 0, read);
|
|
}
|
|
string body = bodyBuilder.ToString();
|
|
return JsonConvert.DeserializeObject<CommerceCatalog>(body).Validate(Bd2Build.Versions.Game);
|
|
}
|
|
}
|
|
|
|
private static CommerceProduct Resolve(string sku)
|
|
{
|
|
try
|
|
{
|
|
if (products == null || catalogOrigin != Origin() || sku == null) return null;
|
|
CashProductTable table = CashShopInfo.GetCashProductDTO(sku, SG.App.MarketType);
|
|
if (table == null || !products.TryGetValue(CommerceProduct.MakeKey(table.GroupId, table.Id, table.SaleGroup, sku), out var policy)) return null;
|
|
if (table == null || table.PriceType != 1 || table.GroupId != policy.GroupId || table.Id != policy.ProductId || table.SaleGroup != policy.SaleGroup ||
|
|
CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType) != sku) return null;
|
|
return policy;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static CommerceProduct Resolve(int group, int id, int sale)
|
|
{
|
|
try
|
|
{
|
|
var table = CashShopInfo.GetCashProductDTO(group, id, sale);
|
|
if (table == null || table.PriceType != 1 || products == null || catalogOrigin != Origin()) return null;
|
|
string sku = CashShopInfo.GetProductIdByMarket(table, SG.App.MarketType);
|
|
return products.TryGetValue(CommerceProduct.MakeKey(group, id, sale, sku), out var policy) ? policy : null;
|
|
}
|
|
catch { return null; }
|
|
}
|
|
|
|
private static bool Purchase(string __0, object __1, Action __2)
|
|
{
|
|
var policy = Resolve(__0);
|
|
if (policy == null || !policy.Enabled)
|
|
{
|
|
__2?.Invoke();
|
|
ShowPurchaseFailure();
|
|
return false;
|
|
}
|
|
if (!HasFunds(policy))
|
|
{
|
|
__2?.Invoke();
|
|
InsufficientFunds(policy);
|
|
return false;
|
|
}
|
|
long pay = System.Threading.Interlocked.Increment(ref paySequence);
|
|
var result = new PurchaseData(__0, pay, "bd2-local:" + pay, CommercePayments.Receipt(pay, policy));
|
|
(__1 as Delegate)?.DynamicInvoke(result);
|
|
return false;
|
|
}
|
|
|
|
private static bool Suppress() => false;
|
|
private static bool ProductPrefetch(Action __3) { __3?.Invoke(); return false; }
|
|
private static bool LocalPriceLocalization(Action __1)
|
|
{
|
|
Log?.LogInfo("Commerce SDK suppressed: operation=price-localization reason=server-owned-cash-shop");
|
|
__1?.Invoke();
|
|
return false;
|
|
}
|
|
private static bool SkipOfficialBilling()
|
|
{
|
|
Log?.LogInfo("Commerce SDK suppressed: operation=gpg-billing-initialization reason=server-owned-cash-shop");
|
|
return false;
|
|
}
|
|
private static bool LocalBillingStatus(Action<bool?> __0)
|
|
{
|
|
Log?.LogInfo("Commerce SDK suppressed: operation=gpg-oauth-status reason=server-owned-cash-shop");
|
|
__0?.Invoke(false);
|
|
return false;
|
|
}
|
|
private static bool LocalSplitPrices(Action __2)
|
|
{
|
|
Log?.LogInfo("Commerce SDK suppressed: operation=gpg-split-price-localization reason=server-owned-cash-shop");
|
|
__2?.Invoke();
|
|
return false;
|
|
}
|
|
private static bool PriceNumber(ref string __result) { __result = "0"; return false; }
|
|
private static bool CurrencyCode(ref string __result) { __result = "BD2"; return false; }
|
|
|
|
private static bool BeforeRequest(int __0, int __1, int __2, long __3, string __5, ref Action __7, Action<int> __8, ref Action __9)
|
|
{
|
|
CommerceProduct quote = Resolve(__0, __1, __2);
|
|
if (quote == null || !quote.Enabled || quote.Sku != __5)
|
|
{
|
|
__8?.Invoke(1);
|
|
ShowPurchaseFailure();
|
|
return false;
|
|
}
|
|
if (!HasFunds(quote))
|
|
{
|
|
__8?.Invoke(1);
|
|
InsufficientFunds(quote);
|
|
return false;
|
|
}
|
|
// Cash products retain PriceType=Cash, so the original success handler has
|
|
// no local subtraction. Apply the accepted quote once, only after success.
|
|
Action continuation = __7;
|
|
long payment = __3;
|
|
string origin = catalogOrigin;
|
|
object user = CommonPacket.UserInfo;
|
|
__7 = CommercePayments.AcceptOnce(payment, () => origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo), delegate
|
|
{
|
|
if (quote.Amount > 0)
|
|
{
|
|
CommonPacket.RemoveHaveCurrency((EElementType)quote.ItemType, quote.Amount, false, true);
|
|
UIManager.UpdateUseCurrency((EElementType)quote.ItemType, ECurrencyDirectDisplayType.FadeOutOnlySubtractionCurrency, quote.Amount, 0, null, false);
|
|
}
|
|
}, continuation);
|
|
// The server has already supplied the real ticket expiry. Prevent the
|
|
// client's additional +30-day estimate and reload authoritative claims.
|
|
Action bought = __9;
|
|
__9 = delegate
|
|
{
|
|
bought?.Invoke();
|
|
if (origin == catalogOrigin && ReferenceEquals(user, CommonPacket.UserInfo) && attendanceRefreshPayments.Add(payment))
|
|
RefreshAttendanceIndependent();
|
|
};
|
|
return true;
|
|
}
|
|
|
|
private static bool HasFunds(CommerceProduct quote) => quote.Amount == 0 ||
|
|
CommonPacket.UserInfo != null && CommonPacket.GetHaveCurrencyCount((EElementType)quote.ItemType, false) >= quote.Amount;
|
|
|
|
private static void ShowPurchaseFailure() =>
|
|
UIManager.OpenSelectiveMessagePopupUI(Unavailable, "购买失败", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK);
|
|
|
|
private static bool SuppressExternalPaymentConfirmation(string __1, string __2)
|
|
{
|
|
// Only the official cash checkout prompt is removed. Its callers proceed
|
|
// synchronously to our Purchase prefix, which opens the failure dialog
|
|
// for disabled goods or performs the server-owned purchase for enabled goods.
|
|
return __1 != LocalTextInfo.GetText(13104) || __2 != LocalTextInfo.GetText(13103);
|
|
}
|
|
|
|
private static void InsufficientFunds(CommerceProduct quote)
|
|
{
|
|
if (quote.ItemType == 2 || quote.ItemType == 3)
|
|
UIManager.OpenCashShopLinkPopupUI((EElementType)quote.ItemType, 0, ShopLinkPopupUI.EExitFieldMapType.None, null, null, null);
|
|
else UIManager.OpenSelectiveMessagePopupUI(LocalTextInfo.GetText(154), "", MessagePopupUI.EMessagePopupButtonStyle.ONLY_OK);
|
|
}
|
|
|
|
private static bool PriceLabel(string __0, ref string __result)
|
|
{
|
|
var policy = Resolve(__0);
|
|
string text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture);
|
|
__result = Marker + Uri.EscapeDataString(__0 ?? "") + ">" + text + "</link>";
|
|
return false;
|
|
}
|
|
|
|
private static CommerceProduct FromLabel(string label)
|
|
{
|
|
if (label == null) return null;
|
|
int start = label.IndexOf(Marker, StringComparison.Ordinal);
|
|
if (start < 0) return null;
|
|
start += Marker.Length;
|
|
int end = label.IndexOf('>', start);
|
|
return end < 0 ? null : Resolve(Uri.UnescapeDataString(label.Substring(start, end - start)));
|
|
}
|
|
|
|
private static bool SetCash(CurrencyButton __instance, string __0)
|
|
{
|
|
RestorePricePrefixes(__instance);
|
|
if (__0 == null || !__0.Contains(Marker)) return true;
|
|
string sku = ExtractSku(__0);
|
|
priceViews[__instance] = () => Render(__instance, Resolve(sku));
|
|
Render(__instance, FromLabel(__0));
|
|
return false;
|
|
}
|
|
|
|
private static void Render(CurrencyButton button, CommerceProduct policy)
|
|
{
|
|
if (button == null) return;
|
|
RestorePricePrefixes(button);
|
|
button.SetActive(true);
|
|
if (policy == null || !policy.Enabled || policy.ItemType == 0)
|
|
{
|
|
button.SetCash(policy != null && policy.Enabled ? "Free" : Unavailable);
|
|
return;
|
|
}
|
|
button.Set((EElementType)policy.ItemType, 0, policy.Amount);
|
|
button.SetEnough(CommonPacket.GetHaveCurrencyCount((EElementType)policy.ItemType, false) >= policy.Amount);
|
|
}
|
|
|
|
private static void RestorePricePrefixes(CurrencyButton button)
|
|
{
|
|
if (button == null || !unavailablePriceLabels.TryGetValue(button, out var saved)) return;
|
|
foreach (var entry in saved)
|
|
if (entry.Key != null)
|
|
{
|
|
entry.Key.text = entry.Value.Text;
|
|
entry.Key.gameObject.SetActive(entry.Value.Active);
|
|
if (entry.Value.Layout)
|
|
{
|
|
entry.Key.alignment = entry.Value.Alignment;
|
|
entry.Key.enableWordWrapping = entry.Value.Wrap;
|
|
RectTransform rect = entry.Key.rectTransform;
|
|
rect.anchorMin = entry.Value.AnchorMin; rect.anchorMax = entry.Value.AnchorMax;
|
|
rect.pivot = entry.Value.Pivot; rect.offsetMin = entry.Value.OffsetMin; rect.offsetMax = entry.Value.OffsetMax;
|
|
if (entry.Value.Drivers != null)
|
|
foreach (var driver in entry.Value.Drivers) if (driver.Key != null) driver.Key.enabled = driver.Value;
|
|
}
|
|
}
|
|
unavailablePriceLabels.Remove(button);
|
|
}
|
|
|
|
private static void PopupPrice(CashShopBuyPopupUI __instance, EElementType __3, string __7)
|
|
{
|
|
CurrencyButton button = Field<CurrencyButton>(__instance, "_currencyButton");
|
|
if (button == null) return;
|
|
RestorePricePrefixes(button);
|
|
priceViews.Remove(button);
|
|
if (__3 != EElementType.Cash) return;
|
|
string sku = __7;
|
|
priceViews[button] = () => { Render(button, Resolve(sku)); ShowUnavailablePopupPrice(__instance, button, Resolve(sku)); };
|
|
ShowUnavailablePopupPrice(__instance, button, Resolve(sku));
|
|
}
|
|
|
|
private static void ShowUnavailablePopupPrice(CashShopBuyPopupUI popup, CurrencyButton button, CommerceProduct policy)
|
|
{
|
|
if (policy != null && policy.Enabled) return;
|
|
// CurrencyParent is the gray price strip. Never search its parent,
|
|
// which also contains the separate confirmation and cancellation buttons.
|
|
Transform container = button.transform;
|
|
GameObject buy = Field<GameObject>(popup, "_objBuyButton"), cancel = Field<GameObject>(popup, "_objCancelButton");
|
|
TMP_Text price = button.TextPrice;
|
|
var saved = new Dictionary<TMP_Text, PriceLabelState>();
|
|
foreach (var label in container.GetComponentsInChildren<TMP_Text>(true))
|
|
{
|
|
if (label == price || buy != null && label.transform.IsChildOf(buy.transform) || cancel != null && label.transform.IsChildOf(cancel.transform)) continue;
|
|
if (label.text != "购买费用") continue;
|
|
RectTransform rect = label.rectTransform;
|
|
saved[label] = new PriceLabelState { Text = label.text, Active = label.gameObject.activeSelf,
|
|
Layout = true, Alignment = label.alignment, Wrap = label.enableWordWrapping,
|
|
AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot,
|
|
OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax };
|
|
var drivers = new Dictionary<Behaviour, bool>();
|
|
foreach (var group in container.GetComponentsInChildren<UnityEngine.UI.LayoutGroup>(true))
|
|
{ drivers[group] = group.enabled; group.enabled = false; }
|
|
foreach (var fitter in container.GetComponentsInChildren<UnityEngine.UI.ContentSizeFitter>(true))
|
|
{ drivers[fitter] = fitter.enabled; fitter.enabled = false; }
|
|
saved[label].Drivers = drivers;
|
|
label.text = Unavailable;
|
|
label.gameObject.SetActive(true);
|
|
label.alignment = TextAlignmentOptions.Center;
|
|
label.enableWordWrapping = false;
|
|
rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f);
|
|
rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero;
|
|
}
|
|
if (saved.Count == 0)
|
|
{
|
|
Log?.LogWarning("Commerce price layout: fee label missing inside CurrencyParent; centering existing price label");
|
|
if (price == null) return;
|
|
RectTransform rect = price.rectTransform;
|
|
saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf,
|
|
Layout = true, Alignment = price.alignment, Wrap = price.enableWordWrapping,
|
|
AnchorMin = rect.anchorMin, AnchorMax = rect.anchorMax, Pivot = rect.pivot,
|
|
OffsetMin = rect.offsetMin, OffsetMax = rect.offsetMax };
|
|
var drivers = new Dictionary<Behaviour, bool>();
|
|
foreach (var group in container.GetComponentsInChildren<UnityEngine.UI.LayoutGroup>(true))
|
|
{ drivers[group] = group.enabled; group.enabled = false; }
|
|
foreach (var fitter in container.GetComponentsInChildren<UnityEngine.UI.ContentSizeFitter>(true))
|
|
{ drivers[fitter] = fitter.enabled; fitter.enabled = false; }
|
|
saved[price].Drivers = drivers;
|
|
price.text = Unavailable;
|
|
price.gameObject.SetActive(true);
|
|
price.alignment = TextAlignmentOptions.Center;
|
|
price.enableWordWrapping = false;
|
|
rect.anchorMin = Vector2.zero; rect.anchorMax = Vector2.one; rect.pivot = new Vector2(0.5f, 0.5f);
|
|
rect.offsetMin = Vector2.zero; rect.offsetMax = Vector2.zero;
|
|
unavailablePriceLabels[button] = saved;
|
|
return;
|
|
}
|
|
if (price != null)
|
|
{
|
|
saved[price] = new PriceLabelState { Text = price.text, Active = price.gameObject.activeSelf };
|
|
price.gameObject.SetActive(false);
|
|
}
|
|
unavailablePriceLabels[button] = saved;
|
|
}
|
|
|
|
private static T Field<T>(object target, string name)
|
|
{
|
|
for (Type type = target.GetType(); type != null; type = type.BaseType)
|
|
{
|
|
FieldInfo field = type.GetGameField(name, All | BindingFlags.DeclaredOnly);
|
|
if (field != null) return (T)field.GetValue(target);
|
|
}
|
|
throw new MissingFieldException(target.GetType().FullName, name);
|
|
}
|
|
|
|
private static void PackagePrice(PackagePurchaseButton __instance)
|
|
{
|
|
priceViews.Remove(__instance);
|
|
int group = Field<int>(__instance, "_productGroupId"), id = Field<int>(__instance, "_productId"), sale = Field<int>(__instance, "_productSaleGroupId");
|
|
var table = CashShopInfo.GetCashProductDTO(group, id, sale);
|
|
if (table == null || table.PriceType != 1) return;
|
|
var button = Field<CurrencyButton>(__instance, "_btnCurrency");
|
|
priceViews[__instance] = () => PackagePrice(__instance);
|
|
CommerceProduct policy = Resolve(group, id, sale);
|
|
TMP_Text label = Field<TMP_Text>(__instance, "_textPrice");
|
|
RestorePricePrefixes(button);
|
|
if (policy == null || !policy.Enabled)
|
|
{
|
|
if (button != null) button.SetActive(false);
|
|
if (label != null) { label.text = Unavailable; label.gameObject.SetActive(true); }
|
|
return;
|
|
}
|
|
Render(button, policy);
|
|
if (button != null) label?.gameObject.SetActive(false);
|
|
}
|
|
|
|
private static void PassPrice(PassRootUI __instance, bool __result)
|
|
{
|
|
priceViews.Remove(__instance);
|
|
if (!__result) return;
|
|
var table = PassInfo.GetPassBuyTable(Field<int>(__instance, "_curPassTableID"), Define_PassBuyType.PbPremium2);
|
|
priceViews[__instance] = () => PassPrice(__instance, true);
|
|
Render(Field<UISprite>(__instance, "_imgPremiumBuyPrice"), Field<TMP_Text>(__instance, "_txtPremiumBuyPrice"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup));
|
|
}
|
|
|
|
private static void PassPopupPrice(BuyConfirmPopupUI __instance, int __0, Define_PassBuyType __1)
|
|
{
|
|
priceViews.Remove(__instance);
|
|
if (__1 != Define_PassBuyType.PbPremium2) return;
|
|
var table = PassInfo.GetPassBuyTable(__0, __1);
|
|
int pass = __0;
|
|
Define_PassBuyType buy = __1;
|
|
priceViews[__instance] = () => PassPopupPrice(__instance, pass, buy);
|
|
Render(Field<UISprite>(__instance, "_uiSpriteCurrency"), Field<TMP_Text>(__instance, "_textCurrency"), Resolve(table.CashProductGroupId, table.CashProductId, table.CashSalesGroup));
|
|
}
|
|
|
|
private static void Render(UISprite sprite, TMP_Text text, CommerceProduct policy)
|
|
{
|
|
if (sprite != null)
|
|
{
|
|
sprite.gameObject.SetActive(policy != null && policy.Enabled && policy.ItemType != 0);
|
|
if (policy != null && policy.Enabled && policy.ItemType != 0) sprite.SetSprite(CurrencyInfo.GetCurrencyDTO((EElementType)policy.ItemType).IconSpriteNameSmall);
|
|
}
|
|
if (text != null) text.text = policy == null || !policy.Enabled ? Unavailable : policy.ItemType == 0 ? "Free" : policy.Amount.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
|
|
private static void GachaPrice(GachaProductButtonElement __instance, string __4)
|
|
{
|
|
var buttons = Field<CurrencyButton[]>(__instance, "_currencyGachas");
|
|
if (buttons == null || buttons.Length == 0) return;
|
|
foreach (var button in buttons) if (button != null) priceViews.Remove(button);
|
|
if (__4 == null || !__4.Contains(Marker)) return;
|
|
string sku = ExtractSku(__4);
|
|
priceViews[buttons[0]] = () => Render(buttons[0], Resolve(sku));
|
|
foreach (var button in buttons) button?.SetActive(false);
|
|
Render(buttons[0], FromLabel(__4));
|
|
Field<TMP_Text>(__instance, "_textDescription").text = "";
|
|
}
|
|
|
|
private static void AttendanceRewards(IMessage __0, int __2, bool __result)
|
|
{
|
|
if (__2 != 0 || !__result) return;
|
|
try
|
|
{
|
|
if (!AttendanceRewardEnvelope.TryRead(__0, out ByteString encoded, out string receipt)) return;
|
|
RewardDBInfoBundle bundle = RewardDBInfoBundle.Parser.ParseFrom(encoded);
|
|
string key = Origin() + "/" + receipt;
|
|
if (!attendanceReceipts.Add(key)) return;
|
|
var rewards = new List<ItemBaseInfo>();
|
|
DataManager.AddRewardInfoBundle(bundle, ref rewards, true);
|
|
UIManager.RefreshCurrencyUI();
|
|
UIManager.OpenCharNoticeUI(bundle.CharInfo, bundle.ItemAutoExchangeInfo, bundle.ItemAutoUpgradeInfo);
|
|
}
|
|
catch (Exception error) { Log?.LogWarning("Attendance commerce rewards could not be applied: " + error.Message); }
|
|
}
|
|
|
|
private static void LoginPassRewards(byte[] __0, int __2, bool __result)
|
|
{
|
|
if (__2 == 0 && __result) ApplyRewardEnvelope(AttendanceResponse.Parser.ParseFrom(__0));
|
|
}
|
|
|
|
private static void EventRewards(byte[] __0, int __2, bool __result)
|
|
{
|
|
if (__2 == 0 && __result) ApplyRewardEnvelope(EventRewardResponse.Parser.ParseFrom(__0));
|
|
}
|
|
|
|
private static void ApplyRewardEnvelope(IMessage message) => AttendanceRewards(message, 0, true);
|
|
|
|
private static void RefreshAttendanceIndependent()
|
|
{
|
|
// Own the receiver rather than setting EventPacket's single static
|
|
// onAttendanceRespSuccess slot, which may belong to an in-flight UI.
|
|
string origin = catalogOrigin;
|
|
object user = CommonPacket.UserInfo;
|
|
SG.Net.Send(new AttendanceRequest { Seq = BDNetwork.Sequence.ReqSeq }, (data, packetCode, error) =>
|
|
{
|
|
if (error != 0 || origin != catalogOrigin || !ReferenceEquals(user, CommonPacket.UserInfo)) return false;
|
|
AttendanceResponse response = AttendanceResponse.Parser.ParseFrom(data);
|
|
CashShopPacket.RefreshAllAttendanceInfo(response.AttendanceInfo);
|
|
CashShopPacket.RefreshSubscribeAttendanceInfo(response.SubscribeAttendanceInfo);
|
|
CashShopPacket.RefreshAttendancePackageInfo(response.AttendancePackageInfo);
|
|
CashShopPacket.RefreshLoginPassPackageInfo(response.LoginPassPackageInfo);
|
|
ApplyRewardEnvelope(response);
|
|
return true;
|
|
}, false, null, false, null);
|
|
}
|
|
|
|
private static string ExtractSku(string label)
|
|
{
|
|
int start = label.IndexOf(Marker, StringComparison.Ordinal) + Marker.Length;
|
|
int end = label.IndexOf('>', start);
|
|
return end < 0 ? null : Uri.UnescapeDataString(label.Substring(start, end - start));
|
|
}
|
|
|
|
private static void RefreshPrices()
|
|
{
|
|
foreach (var view in priceViews.ToArray())
|
|
{
|
|
if (view.Key == null) { priceViews.Remove(view.Key); continue; }
|
|
try { view.Value(); } catch (Exception error) { Log?.LogWarning("Commerce UI refresh: " + error.Message); }
|
|
}
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
// Unity may remove the BepInEx component during startup. Static hooks and
|
|
// the separate persistent host must survive that scene cleanup.
|
|
Log?.LogInfo("Cash shop plugin component destroyed: billing hooks retained on persistent runtime");
|
|
}
|
|
}
|