using System; using System.Threading; using BrewMonster; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.SceneManagement; namespace BrewMonster.Scripts { /// /// Bootstrap: lấy contentVersion + (tuỳ chọn) assetsBaseUrl qua server hoặc hardcode, gắn URL rewrite trước khi Addressables init, /// rồi nếu lần đầu hoặc contentVersion khác đã lưu thì cập nhật catalog và tải toàn bộ entry gắn label. /// First run / version bump: resolve version + optional assetsBaseUrl (server or hardcoded), apply URL rewrite before Addressables init, /// then if first launch or stored version differs, update catalogs and download all entries under the configured label. /// [DefaultExecutionOrder(-2000)] public class GameContentBootstrap : MonoBehaviour { const string PrefsFirstSyncDone = "PW_GameContent_FirstRemoteSyncDone"; const string PrefsLastContentVersion = "PW_GameContent_LastContentVersion"; /// /// When non-null, waits on this before . /// static UniTaskCompletionSource s_addressablesInitGate; static int s_activeBootstrapInstanceId; static bool s_bootstrapRunStarted; [Header("Debug")] [Tooltip("Log lifecycle (Awake/Start/OnDestroy), gate state, scene name — dùng khi debug mobile.")] [SerializeField] bool _verboseLifecycleDebug = true; [Tooltip("Nếu gate chưa mở sau N giây, tự release để AddressableManager không treo vĩnh viễn.")] [SerializeField] float _gateReleaseTimeoutSeconds = 45f; [Header("Version source")] [Tooltip("True = gọi HTTP tới URL bên dưới. False = không request server, chỉ dùng hardcode (test / API chưa chốt).")] [SerializeField] bool _useServerForVersionInfo; [Header("Hardcoded (khi tắt server hoặc Editor skip remote)")] [Tooltip("Dùng khi _useServerForVersionInfo = false, hoặc Editor bật Skip Remote (ưu tiên field này nếu có).")] [SerializeField] string _hardcodedContentVersion = "0"; [Tooltip("Tuỳ chọn: base URL CDN mới (rewrite), để trống nếu không đổi URL runtime.")] [SerializeField] string _hardcodedAssetsBaseUrl; [Header("Version API (chỉ khi bật server)")] [Tooltip("URL đầy đủ (https://...). Server trả JSON: contentVersion + tuỳ chọn assetsBaseUrl (prefix CDN). / Full URL; server returns JSON: contentVersion + optional assetsBaseUrl (CDN prefix).")] [SerializeField] string _versionEndpointUrl; [Tooltip("Bật = POST JSON body bên dưới; tắt = GET (không body). Server kiểm tra body rồi trả cùng format JSON. / On = POST JSON body below; off = GET.")] [SerializeField] bool _usePostForVersionRequest; [Tooltip("Body POST (JSON). Để trống → gửi \"{}\". Ví dụ: {\"userId\":\"...\",\"platform\":\"Android\"}. / POST JSON body; empty sends \"{}\".")] [TextArea(2, 8)] [SerializeField] string _versionPostBody; [SerializeField] int _requestTimeoutSeconds = 20; [Header("Addressables")] [Tooltip("If true, blocks Addressables init until version resolve + optional URL rewrite finish (recommended).")] [SerializeField] bool _holdAddressablesInitUntilVersionChecked = true; [Tooltip("Prefix baked into remote InternalIds at build (Remote.LoadPath). When assetsBaseUrl is set, replace this prefix.")] [SerializeField] string _bakedRemoteUrlPrefixForRewrite; [Tooltip("Label applied in Addressables Groups to every remote asset that must download on first run / content update.")] [SerializeField] string _remoteBulkDownloadLabel = "RemoteContent"; [Header("Editor")] [Tooltip("Editor: không gọi HTTP server; dùng hardcode (nếu có version) hoặc _editorFakeContentVersion.")] [SerializeField] bool _skipRemoteCallInEditor; [SerializeField] string _editorFakeContentVersion = "editor"; public event Action Finished; /// Trạng thái gate tĩnh — dùng log chẩn đoán từ . public static string GetGateDebugState() { if (!_holdAddressablesInitConfigured) return "holdAddressablesInit=false (no gate)"; if (s_addressablesInitGate == null) return "gate=null (released or not created)"; return $"gate=pending, activeInstanceId={s_activeBootstrapInstanceId}, runStarted={s_bootstrapRunStarted}"; } static bool _holdAddressablesInitConfigured = true; public static UniTask WaitForPreAddressablesSetupIfAnyAsync(CancellationToken cancellationToken = default) { if (s_addressablesInitGate == null) return UniTask.CompletedTask; return s_addressablesInitGate.Task.AttachExternalCancellation(cancellationToken); } void Awake() { s_activeBootstrapInstanceId = GetInstanceID(); _holdAddressablesInitConfigured = _holdAddressablesInitUntilVersionChecked; LogLifecycle("Awake"); if (_holdAddressablesInitUntilVersionChecked) { s_addressablesInitGate ??= new UniTaskCompletionSource(); GateTimeoutWatchdog().Forget(); } else LogLifecycle("Awake: holdAddressablesInit=false — AddressableManager will not wait on gate."); TryStartBootstrapRun("Awake"); } void Start() { LogLifecycle("Start"); TryStartBootstrapRun("Start"); } void OnDestroy() { LogLifecycle("OnDestroy — releasing gate if still pending"); ReleaseGateIfPending("OnDestroy"); if (s_activeBootstrapInstanceId == GetInstanceID()) s_activeBootstrapInstanceId = 0; } void OnApplicationQuit() { LogLifecycle("OnApplicationQuit"); ReleaseGateIfPending("OnApplicationQuit"); } void TryStartBootstrapRun(string caller) { if (!isActiveAndEnabled) { LogLifecycle($"{caller}: SKIP bootstrap — GameObject inactive or component disabled."); return; } if (s_bootstrapRunStarted) { LogLifecycle($"{caller}: bootstrap already started (skip duplicate)."); return; } s_bootstrapRunStarted = true; LogLifecycle($"{caller}: starting RunAsync."); RunAsync().Forget(); } async UniTaskVoid GateTimeoutWatchdog() { var timeout = Mathf.Max(5f, _gateReleaseTimeoutSeconds); await UniTask.Delay(TimeSpan.FromSeconds(timeout)); if (s_addressablesInitGate == null) return; Debug.LogWarning( $"[Cuong] GameContentBootstrap: Gate timeout ({timeout:F0}s) — releasing gate. " + $"runStarted={s_bootstrapRunStarted}, scene={SceneManager.GetActiveScene().name}, {DescribeSelf()}"); AllowAddressablesInit("GateTimeoutWatchdog"); } static void ReleaseGateIfPending(string reason) { if (s_addressablesInitGate == null) return; Debug.LogWarning( $"[Cuong] GameContentBootstrap: ReleaseGate ({reason}) — gate opened without normal bootstrap completion."); s_addressablesInitGate.TrySetResult(); s_addressablesInitGate = null; } void LogLifecycle(string phase) { if (!_verboseLifecycleDebug) return; Debug.Log( $"[Cuong] GameContentBootstrap: {phase} | {DescribeSelf()} | gate={GetGateDebugState()} | " + $"scene={SceneManager.GetActiveScene().name} frame={Time.frameCount}"); } string DescribeSelf() { return $"id={GetInstanceID()} active={gameObject.activeInHierarchy} enabled={enabled} " + $"holdInit={_holdAddressablesInitUntilVersionChecked} server={_useServerForVersionInfo}"; } async UniTaskVoid RunAsync() { var result = await RunInternalAsync(); Finished?.Invoke(result); } public async UniTask RunInternalAsync() { try { Debug.Log("[Cuong] GameContentBootstrap: Bắt đầu bootstrap Addressables..."); Debug.Log("[Cuong] GameContentBootstrap: Đang lấy contentVersion / assetsBaseUrl..."); var fetch = await ResolveVersionFetchAsync(); if (!fetch.Ok) { Debug.LogError($"[Cuong] GameContentBootstrap: Lỗi lấy version — {fetch.Error}"); AllowAddressablesInit("version-fetch-failed"); return new BootstrapResult(false, false, fetch.Error, null); } Debug.Log($"[Cuong] GameContentBootstrap: Đã có contentVersion={fetch.ContentVersion}" + (string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) ? "" : $", assetsBaseUrl={fetch.AssetsBaseUrl}")); if (!string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) && !string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite)) { AddressablesRuntimeUrlRewriter.InstallPrefixRewrite( _bakedRemoteUrlPrefixForRewrite, fetch.AssetsBaseUrl); Debug.Log( $"[Cuong] GameContentBootstrap: URL rewrite | from={_bakedRemoteUrlPrefixForRewrite.Trim()} → to={fetch.AssetsBaseUrl.Trim()}"); } else if (!string.IsNullOrWhiteSpace(fetch.AssetsBaseUrl) && string.IsNullOrWhiteSpace(_bakedRemoteUrlPrefixForRewrite)) { Debug.LogWarning( "[Cuong] GameContentBootstrap: assetsBaseUrl có nhưng _bakedRemoteUrlPrefixForRewrite trống — " + "catalog/bundle vẫn dùng URL bake trong build (vd CDN cũ). Điền prefix URL lúc build."); } AllowAddressablesInit("version-and-url-ready"); Debug.Log("[Cuong] GameContentBootstrap: Đang khởi tạo Addressables..."); await AddressablesCatalogUpdater.EnsureInitializedAsync(); Debug.Log("[Cuong] GameContentBootstrap: Addressables đã khởi tạo."); bool firstRun = PlayerPrefs.GetInt(PrefsFirstSyncDone, 0) == 0; string localVersion = PlayerPrefs.GetString(PrefsLastContentVersion, string.Empty); bool needContentWork = firstRun || !string.Equals(localVersion, fetch.ContentVersion ?? string.Empty, StringComparison.Ordinal); if (!needContentWork) { BMLogger.Log("[Cuong] GameContentBootstrap: contentVersion unchanged, skip catalog/bulk download."); Debug.Log("[Cuong] GameContentBootstrap: Hoàn tất — không cần tải catalog/bulk (version không đổi)."); return new BootstrapResult(true, false, null, fetch.ContentVersion); } Debug.Log($"[Cuong] GameContentBootstrap: Cần sync nội dung (firstRun={firstRun}, localVersion='{localVersion}', serverVersion='{fetch.ContentVersion}')."); Debug.Log("[Cuong] GameContentBootstrap: Đang kiểm tra / cập nhật catalog..."); var catalog = await AddressablesCatalogUpdater.CheckAndUpdateCatalogsIfNeededAsync(false); if (!catalog.Success) { var errMsg = catalog.Error?.Message ?? "Catalog update failed"; Debug.LogError($"[Cuong] GameContentBootstrap: Catalog update thất bại — {errMsg}"); if (IsLikelyRemoteTlsOrNetworkError(errMsg)) { Debug.LogError( "[Cuong] GameContentBootstrap: Gợi ý — SSL CA / mạng trên mobile. " + "Kiểm tra chuỗi chứng chỉ CDN; đảm bảo _bakedRemoteUrlPrefixForRewrite khớp host trong catalog build " + "(vd https://prefect-world-asset....wcsapi.com/) và assetsBaseUrl trỏ CDN HTTPS hợp lệ. " + "Không gọi Addressables.InitializeAsync trước bootstrap (AUIManager đã dùng AddressablesInitService)."); } return new BootstrapResult(false, true, errMsg, fetch.ContentVersion); } Debug.Log("[Cuong] GameContentBootstrap: Catalog OK."); if (string.IsNullOrWhiteSpace(_remoteBulkDownloadLabel)) { BMLogger.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel is empty; skipping bulk download."); Debug.LogError("[Cuong] GameContentBootstrap: _remoteBulkDownloadLabel trống — bỏ bulk download."); return new BootstrapResult(false, true, "remoteBulkDownloadLabel empty", fetch.ContentVersion); } var bulkLabel = _remoteBulkDownloadLabel.Trim(); var downloadBytes = await AddressablesCatalogUpdater.GetDownloadSizeBytesAsync(bulkLabel); if (downloadBytes > 0) { Debug.Log( $"[Cuong] GameContentBootstrap: Đang tải remote content (label={bulkLabel}), dung lượng cần tải ~{downloadBytes / (1024f * 1024f):F1} MB..."); } else if (downloadBytes == 0) { Debug.Log( $"[Cuong] GameContentBootstrap: Remote content (label={bulkLabel}) đã có trong cache — vẫn chạy DownloadDependencies để đồng bộ."); } else { Debug.Log( $"[Cuong] GameContentBootstrap: Đang tải remote content (label={bulkLabel}) — không lấy được dung lượng trước khi tải."); } bool dl = await AddressablesCatalogUpdater.DownloadDependenciesAsync(bulkLabel); if (!dl) { Debug.LogError("[Cuong] GameContentBootstrap: Tải remote content thất bại."); return new BootstrapResult(false, true, "DownloadDependencies failed", fetch.ContentVersion); } PlayerPrefs.SetString(PrefsLastContentVersion, fetch.ContentVersion); PlayerPrefs.SetInt(PrefsFirstSyncDone, 1); PlayerPrefs.Save(); BMLogger.Log($"[Cuong] GameContentBootstrap: content sync OK, version={fetch.ContentVersion}"); Debug.Log($"[Cuong] GameContentBootstrap: Bootstrap hoàn tất — đã tải xong hết nội dung (version={fetch.ContentVersion})."); return new BootstrapResult(true, true, null, fetch.ContentVersion); } catch (Exception e) { BMLogger.LogError($"[Cuong] GameContentBootstrap: {e.Message}"); Debug.LogError($"[Cuong] GameContentBootstrap: Exception — {e.Message}"); AllowAddressablesInit("exception"); return new BootstrapResult(false, true, e.Message, null); } } static void AllowAddressablesInit(string reason) { if (s_addressablesInitGate == null) { Debug.Log($"[Cuong] GameContentBootstrap: AllowAddressablesInit({reason}) — gate already null."); return; } Debug.Log($"[Cuong] GameContentBootstrap: AllowAddressablesInit({reason}) — opening gate for AddressableManager."); s_addressablesInitGate.TrySetResult(); s_addressablesInitGate = null; } /// /// Editor skip → hardcode / fake; không server → hardcode; bật server → (GET hoặc POST). /// async UniTask ResolveVersionFetchAsync() { #if UNITY_EDITOR if (_skipRemoteCallInEditor) { BMLogger.Log("[Cuong] GameContentBootstrap: Editor skip remote — no HTTP."); return BuildHardcodedOrEditorResult(); } #endif if (!_useServerForVersionInfo) { BMLogger.Log("[Cuong] GameContentBootstrap: server disabled — using hardcoded version / URL."); return BuildHardcodedResult(); } if (string.IsNullOrWhiteSpace(_versionEndpointUrl)) { BMLogger.LogWarning("[Cuong] GameContentBootstrap: server enabled but URL empty; falling back to hardcoded."); return BuildHardcodedResult(); } return await GameContentVersionServerClient.FetchAsync( _versionEndpointUrl, _requestTimeoutSeconds, _usePostForVersionRequest, _versionPostBody); } GameContentVersionFetchResult BuildHardcodedOrEditorResult() { if (!string.IsNullOrWhiteSpace(_hardcodedContentVersion)) { return new GameContentVersionFetchResult( true, _hardcodedContentVersion.Trim(), TrimOrNull(_hardcodedAssetsBaseUrl), null); } return new GameContentVersionFetchResult( true, _editorFakeContentVersion?.Trim() ?? "editor", TrimOrNull(_hardcodedAssetsBaseUrl), null); } GameContentVersionFetchResult BuildHardcodedResult() { if (string.IsNullOrWhiteSpace(_hardcodedContentVersion)) { BMLogger.LogError("[Cuong] GameContentBootstrap: _hardcodedContentVersion is empty."); return new GameContentVersionFetchResult(false, null, null, "hardcoded contentVersion empty"); } return new GameContentVersionFetchResult( true, _hardcodedContentVersion.Trim(), TrimOrNull(_hardcodedAssetsBaseUrl), null); } static string TrimOrNull(string s) { if (string.IsNullOrWhiteSpace(s)) return null; return s.Trim(); } static bool IsLikelyRemoteTlsOrNetworkError(string message) { if (string.IsNullOrEmpty(message)) return false; return message.IndexOf("SSL", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("certificate", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("ConnectionError", StringComparison.OrdinalIgnoreCase) >= 0 || message.IndexOf("unable to load from url", StringComparison.OrdinalIgnoreCase) >= 0; } public readonly struct BootstrapResult { public BootstrapResult(bool success, bool didContentWork, string errorMessage, string serverContentVersion) { Success = success; DidContentWork = didContentWork; ErrorMessage = errorMessage; ServerContentVersion = serverContentVersion; } public bool Success { get; } /// True if catalog/bulk download ran this session. public bool DidContentWork { get; } public string ErrorMessage { get; } public string ServerContentVersion { get; } } } }