using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using asap.core; using game.PlayerDataStorage; using GameCore; using tysdk; using UnityEngine; namespace game { // One service belongs to one login lifetime. A revoked instance is never rebound. public sealed class KVUserService : IUserService, IKVUserLoadReceiver, IKVUserLifecycle { [Inject] public ICustomServerMgr customServerMgr { get; set; } [Inject] public IPlayerSaveStore SaveStore { get; set; } readonly PlayerKVRuntimeSelection selection; readonly Func handlerFactory; readonly CancellationTokenSource lifetime = new CancellationTokenSource(); readonly Dictionary profiles = new Dictionary(); readonly Dictionary levels = new Dictionary(); PlayerKVGameLoadCoordinator coordinator; PlayerKVGameSessionHandle handle; PlayerKVInitializationIntents intents; bool attempted, loading, closing, published, promptsBound; Task closeTask; long epoch; string playerId; uint internalId; public string CustomId { get; set; } public string UserId => IsAuthorized ? playerId : null; public string ApiPlayerId => UserId; public uint InternalId => IsAuthorized ? internalId : 0; public bool IsLogin => IsAuthorized; public bool IsAuthorized => published && KVHandoffReady; public CancellationToken LoginLifetime => lifetime.Token; public DateTime LastLoginTime { get; private set; } public DateTime LoginTime { get; private set; } public DateTime CreateTime { get; private set; } public string DisplayName { get; private set; } public string AvatarUrl { get; private set; } public string Language { get; private set; } public string ContinentCode { get; private set; } public Dictionary EAccoutUrl { get; private set; } = new Dictionary(); internal int KVTapJoyBalance { get; private set; } internal PlayerKVGameLoadError KVLastError { get; private set; } internal bool KVHandoffReady => !closing && handle != null && coordinator != null && coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle); bool IKVUserLifecycle.KVHandoffReady => KVHandoffReady; PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError; public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { } internal KVUserService(PlayerKVRuntimeSelection selection, Func handlerFactory) { this.selection = selection; this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); Application.quitting += Logout; } public async Task Login(Action progress, bool tyrLoginByToken = true) { if (attempted || closing || selection == null || !selection.IsKV || !selection.TryAdmitPlayer(CustomId, out var canonicalId)) { KVLastError = PlayerKVGameLoadError.Input; return ELoginStep.Api; } // Set before the first await: duplicate calls cannot authenticate or subscribe twice. attempted = true; var requestedId = CustomId; try { if (customServerMgr is not CustomServerMgr server || server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl) throw new InvalidOperationException("Formal KV transport required."); loading = true; var transaction = PlayerKVGameplayBindings.Bind(this); coordinator = new PlayerKVGameLoadCoordinator(transaction, () => loading && !closing && !published && handle == null, () => { }, () => intents?.Count ?? 0, server.BindKVForGame, server.ClearKVForGame); server.KVUnauthorized += OnUnauthorized; var identity = new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none, Array.Empty()); var snapshot = PlayerKVGameplayBindings.Snapshot(DateTime.UtcNow, "formal-v1"); var capability = PlayerKVGameLoadCapability.CreateForGame(identity, snapshot, selection.Api, Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory); var result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime, () => !closing && CustomId == requestedId); KVLastError = result.Error; if (result.State != PlayerKVGameLoadState.GameLoaded || closing || CustomId != requestedId || result.Account == null || result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch || !coordinator.TryGetCurrentGameHandle(result.Epoch, out var original)) { Logout(); return ELoginStep.Api; } epoch = result.Epoch; handle = original; if (SaveStore is not KVPlayerSaveStore store) throw new InvalidOperationException("KV save store required."); store.Bind(original, LoginLifetime); PrepareProfile(result.Account); BindPromptServices(original); // Chat subscribes before the unique success event. No SDK or analytics initialization here. GContext.container.Resolve().WaitingForLogin(); if (!KVHandoffReady || CustomId != requestedId) throw new InvalidOperationException("Stale KV login."); server.EnableKVBusiness(epoch); playerId = result.Account.PlayerId; internalId = result.Account.InternalId; published = true; GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId }); if (!IsAuthorized) return ELoginStep.Api; _ = RunSaveScheduleAsync(store); progress?.Invoke(2); return ELoginStep.Done; } catch (Exception ex) { KVLastError = PlayerKVGameLoadError.Apply; Debug.LogError("KV login failed: " + ex.GetType().Name); Logout(); return ELoginStep.Api; } finally { loading = false; } } void PrepareProfile(PlayerKVAccountDescriptor account) { if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue) throw new InvalidOperationException("Authenticated account metadata required."); CreateTime = account.CreateTimeUtc; LoginTime = account.ServerTimeUtc; var previous = Read("LastLoginTimeUtc"); LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value); DisplayName = Read("DisplayName").Value; if (string.IsNullOrEmpty(DisplayName)) DisplayName = GetDefaultName(account.PlayerId); AvatarUrl = Read("AvatarUrl").Value ?? string.Empty; Language = Read("Language").Value; if (string.IsNullOrEmpty(Language)) Language = GContext.container.Resolve().Lang; var continent = Read("ContinentCode").Value; ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null; } internal static DateTime ParseLastLoginTime(string value, bool found) { if (!found) return DateTime.MinValue; if (!DateTime.TryParseExact(value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var time) || time == DateTime.MinValue || time.Kind == DateTimeKind.Unspecified) throw new FormatException("Invalid LastLoginTimeUtc."); return time.ToUniversalTime(); } PlayerSaveReadResult Read(string key) { var result = SaveStore.Read(key); if (!result.Success) throw new InvalidOperationException("KV profile read failed: " + result.Error); return result; } void BindPromptServices(PlayerKVGameSessionHandle original) { if (promptsBound || !KVHandoffReady) throw new InvalidOperationException("Prompt binding is stale."); var settings = GContext.container.Resolve() as DefaultSettingService; var notifications = GContext.container.Resolve() as LocalNotificationService; var club = GContext.container.Resolve(); if (settings == null || notifications == null || club == null || !ReferenceEquals(settings, GContext.container.Resolve()) || !ReferenceEquals(notifications, GContext.container.Resolve()) || !ReferenceEquals(club, GContext.container.Resolve())) throw new InvalidOperationException("Formal prompt services required."); settings.PreflightFormalPromptOwner(original); club.PreflightFormalPromptOwner(original); notifications.PreflightFormalPromptSettings(settings); promptsBound = true; settings.BindFormalPromptOwner(original); club.BindFormalPromptOwner(original); notifications.BindFormalPromptSettings(settings); if (!settings.HasFormalPromptOwner(original) || !club.HasFormalPromptOwner(original) || !notifications.HasFormalPromptSettings(settings) || !KVHandoffReady) throw new InvalidOperationException("Formal prompt binding changed."); } void OnUnauthorized(int version) { if (version == customServerMgr.SessionVersion) Logout(); } public void Logout() => ObserveClose(CloseKVForValidationAsync()); async void ObserveClose(Task task) { try { await task; } catch (Exception ex) { Debug.LogError("KV close failed: " + ex.GetType().Name); } } internal Task CloseKVForValidationAsync() { if (closeTask != null) return closeTask; closing = true; published = false; (SaveStore as KVPlayerSaveStore)?.Dispose(); playerId = null; internalId = 0; handle = null; epoch = 0; DisplayName = AvatarUrl = Language = ContinentCode = null; ClearPlayInfos(); Application.quitting -= Logout; if (customServerMgr is CustomServerMgr server) { server.KVUnauthorized -= OnUnauthorized; server.DisableKVBusiness(); } // Revoke first; a synchronous cancellation callback must already see invalid identity. closeTask = coordinator?.Revoke() ?? Task.CompletedTask; lifetime.Cancel(); return closeTask; } Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync(); internal Task ObserveKVQuitAsync() => coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready); Task IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync(); void IKVUserLoadReceiver.ResetForKVLoad() { if ((!loading && !closing) || published || handle != null) throw new InvalidOperationException("KV identity must be unpublished during load/reset."); intents?.Discard(); intents = null; EAccoutUrl = new Dictionary(); KVTapJoyBalance = 0; } void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state, long loadEpoch, IReadOnlyDictionary raw) { if (!loading || closing || published || handle != null || state == null || raw == null) throw new InvalidOperationException("KV load receiver is not current."); intents = new PlayerKVInitializationIntents(loadEpoch, state.Channel); foreach (var pair in state.Normalizations) intents.Add(loadEpoch, pair.Key, pair.Value); EAccoutUrl = state.AccountUrls.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal); KVTapJoyBalance = state.TapJoyBalance; } void IKVUserLoadReceiver.DiscardKVIntents() => intents?.Discard(); async Task SaveProfile(string key, string value) { var store = SaveStore; if (!IsAuthorized || store == null) throw new InvalidOperationException("KV identity unavailable."); var result = await store.WriteAndConfirmAsync(key, value, LoginLifetime); if (!IsAuthorized || !ReferenceEquals(store, SaveStore) || !store.IsCurrent || !result.Confirmed) throw new InvalidOperationException("KV profile save was not confirmed."); } async Task RunSaveScheduleAsync(IPlayerSaveStore store) { try { while (IsAuthorized && store.IsCurrent) { await Task.Delay(TimeSpan.FromSeconds(40), LoginLifetime); if (!IsAuthorized || !store.IsCurrent) return; var result = await store.FlushAsync(LoginLifetime); if (!result.Confirmed && !LoginLifetime.IsCancellationRequested) Debug.LogWarning("KV scheduled save: " + result.Status); } } catch (OperationCanceledException) { } catch (Exception ex) { Debug.LogError("KV save scheduler failed: " + ex.GetType().Name); } } public async Task ChangeDisplayName(string newName) { await SaveProfile("DisplayName", newName); DisplayName = newName; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return newName; } public async Task UpdateAvatarUrl(string url) { await SaveProfile("AvatarUrl", url); AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return url; } public async Task UpdateLanguage(string code) { await SaveProfile("Language", code); Language = code; return code; } public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds; // The non-AGG channel has no account-link operation; never manufacture a successful binding. public Task<(int code, int userid)> LinkAccount(EAccoutType type) => Task.FromResult((1, 0)); public Task GetLinkState(EAccoutType type) => Task.FromResult(false); public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length))); public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve().lv : level; public int GetPlayerLv(string id) => levels.TryGetValue(id, out var level) ? level : 1; public Task<(string, int)> GetPlayLv(string id) { var level = id == UserId && IsAuthorized ? (int)GContext.container.Resolve().lv : id != null && levels.TryGetValue(id, out var cachedLevel) ? cachedLevel : -1; return level >= 0 ? Task.FromResult((id, level)) : Task.FromException<(string, int)>(new InvalidOperationException("Public player level has not been loaded.")); } public PlayInfo GetPlayInfo(string id) { if (string.IsNullOrEmpty(id)) return null; if (!profiles.TryGetValue(id, out var profile)) { profile = new PlayInfo { name = GetDefaultName(id) }; profiles.Add(id, profile); SetPlayInfo(id, profile); } return profile; } async void SetPlayInfo(string id, PlayInfo profile) { if (id == UserId) { profile.name = DisplayName; profile.avatarUrl = AvatarUrl; GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl }); return; } var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id }); if (string.IsNullOrEmpty(json)) return; SearchFriendResp result; try { result = Newtonsoft.Json.JsonConvert.DeserializeObject(json); } catch (Newtonsoft.Json.JsonException ex) { Debug.LogError("KV player profile response failed: " + ex.GetType().Name); return; } if (result != null && result.Code > 0 && result.Info != null) { if (!string.IsNullOrEmpty(result.Info.DisplayName)) profile.name = result.Info.DisplayName; profile.avatarUrl = result.Info.AvatarUrl; GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl }); } } public void SetPlayInfo(string id, string name, string avatarUrl) { if (string.IsNullOrEmpty(id)) return; profiles[id] = new PlayInfo { name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name, avatarUrl = avatarUrl }; } public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); } public void UpdateProfile() { if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId, playerProfile = new PlayerProfile { Level = GContext.container.Resolve().lv, Avatar = AvatarUrl, DisplayName = DisplayName } }); } public void UpdateProfileLv() { if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId, playerProfile = new PlayerProfile { Level = GContext.container.Resolve().lv } }); } } }