using game; using Newtonsoft.Json.Linq; using System.Net; static partial class Probe { // Actual KV login, lease, CustomServerMgr and public consumer methods run above this transport. sealed class ProfileCacheWire { internal bool Active; internal readonly List<(string Path, string Body)> Requests = new(); internal Task Next; internal bool TryHandle(HttpRequestMessage request, out Task response) { response = null; if (!Active) return false; Check(request.RequestUri.GetLeftPart(UriPartial.Authority) == "http://s101.local:18080" && request.RequestUri.AbsolutePath == "/friend/SearchFriend" && request.Method == HttpMethod.Post, "profile read uses existing authenticated SearchFriend, no KV/SDK/private target read"); Requests.Add((request.RequestUri.AbsolutePath, request.Content.ReadAsStringAsync().GetAwaiter().GetResult())); response = Next ?? throw new Exception("Unplanned profile request"); Next = null; return true; } } static async Task ProfileCachePFCompatibility() { // Concrete PF service supplies the independent original-contract reference, SDK is the existing sentinel. var pf = new IdentityPFUser(); int events = 0; using var subscription = GContext.OnEvent().Subscribe(_ => events++); Check(pf.GetPlayerLv("FE123456") == 1 && pf.GetPlayerLv("") == 1, "PF display cache misses return 1"); foreach (var level in new[] { 0, -5, 62 }) { pf.SetPlayerLv("FE123456", level); Check(pf.GetPlayerLv("FE123456") == level, "PF display cache preserves stored values"); } Check(pf.GetPlayInfo(null) == null && pf.GetPlayInfo("") == null, "PF empty profile IDs return null"); var own = pf.GetPlayInfo(pf.UserId); Check(ReferenceEquals(own, pf.GetPlayInfo(pf.UserId)) && events == 1, "PF own profile is one cached object and one synchronous event"); pf.SetPlayInfo("FE123456", "Before", "avatar-before"); var first = pf.GetPlayInfo("FE123456"); pf.SetPlayInfo("FE123456", "After", "avatar-after"); Check(!ReferenceEquals(first, pf.GetPlayInfo("FE123456")) && first.name == "Before" && events == 1, "PF explicit profile assignment replaces without mutating old reference or publishing"); pf.ClearPlayInfos(); int sdk = Effects.SDK; var remote = pf.GetPlayInfo("FE123456"); Check(remote.name == "Angler#FE123" && ReferenceEquals(remote, pf.GetPlayInfo("FE123456")) && events == 2 && Effects.SDK == sdk + 1, "PF remote profile still uses original SDK and caches one result"); Check((await pf.GetPlayLv("FE123456")).Item2 == 1 && Effects.SDK == sdk + 2, "PF asynchronous miss queries SDK before original missing-Lv default 1"); Pass("C3-profile-original-PF-cache-and-SDK-reference"); } static async Task ProfileCacheKVCompatibility(KVUserService user, ProfileCacheWire wire, string other) { var failures = new List(); void Expect(bool ok, string label) { assertions++; if (!ok) { failures.Add(label); Console.WriteLine("FAIL C3-profile " + label); } } async Task ExpectUnloaded(string id, string label) { try { await user.GetPlayLv(id); Expect(false, label); } catch (InvalidOperationException) { Expect(true, label); } } int sdk = Effects.SDK; var events = new List(); TaskCompletionSource updated = null; using var subscription = GContext.OnEvent().Subscribe(evt => { events.Add(evt); updated?.TrySetResult(true); }); wire.Active = true; try { user.ClearPlayInfos(); var player = (PlayerData)GContext.container.Values[typeof(PlayerData)]; Expect(user.IsAuthorized && player.lv == 10, "fixture uses already authenticated real KV user and existing player"); Expect(user.GetPlayerLv(other) == 1 && user.GetPlayerLv("") == 1, "unknown synchronous display level is original default 1"); Expect(user.GetPlayerLv(user.UserId) == 1, "synchronous self display also follows original cache contract, not a new live shortcut"); await ExpectUnloaded(other, "display default 1 must not make asynchronous unqueried level succeed"); await ExpectUnloaded(null, "asynchronous null ID keeps prior unloaded failure"); await ExpectUnloaded("", "asynchronous empty ID keeps prior unloaded failure"); Expect((await user.GetPlayLv(user.UserId)).Item2 == 10, "asynchronous authenticated self still reads actual player level before cache population"); try { user.GetPlayerLv(null); Expect(false, "synchronous null ID follows original Dictionary argument failure"); } catch (ArgumentNullException) { Expect(true, "synchronous null ID follows original Dictionary argument failure"); } foreach (var level in new[] { 0, -5, 62 }) { user.SetPlayerLv(other, level); Expect(user.GetPlayerLv(other) == level, "synchronous cached level is not normalized: " + level); if (level < 0) await ExpectUnloaded(other, "asynchronous negative cached level keeps prior failure"); else Expect((await user.GetPlayLv(other)).Item2 == level, "asynchronous nonnegative cache remains available"); } user.SetPlayerLv(user.UserId, 999); Expect(user.GetPlayerLv(user.UserId) == 10, "original self SetPlayerLv uses PlayerData rather than caller value"); user.ClearPlayInfos(); Expect(user.GetPlayerLv(other) == 1 && (await user.GetPlayLv(user.UserId)).Item2 == 10, "clear removes display cache without changing asynchronous self contract"); Expect(wire.Requests.Count == 0, "level methods do not introduce remote level queries"); Expect(user.GetPlayInfo(null) == null && user.GetPlayInfo("") == null, "null and empty profile IDs return null"); var own = user.GetPlayInfo(user.UserId); Expect(own != null && own.name == user.DisplayName && own.avatarUrl == user.AvatarUrl, "own profile uses authenticated name and avatar"); Expect(ReferenceEquals(own, user.GetPlayInfo(user.UserId)) && events.Count == 1 && events[0].id == user.UserId && events[0].name == user.DisplayName, "own profile cached reference and exactly one synchronous success event"); Expect(wire.Requests.Count == 0, "own and empty profile lookups send no request"); user.SetPlayInfo(other, "Before", "avatar-before"); var before = user.GetPlayInfo(other); int beforeEvents = events.Count; user.SetPlayInfo(other, "After", "avatar-after"); Expect(!ReferenceEquals(before, user.GetPlayInfo(other)) && before.name == "Before" && events.Count == beforeEvents, "explicit assignment replaces the cache object without mutating old reference or publishing"); user.SetPlayInfo(other, "", null); Expect(user.GetPlayInfo(other).name == "Angler#" + other && user.GetPlayInfo(other).avatarUrl == null, "explicit empty nickname retains original default-name rule"); user.ClearPlayInfos(); events.Clear(); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); wire.Next = release.Task; updated = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); int requestStart = wire.Requests.Count; var placeholder = user.GetPlayInfo(other); Expect(placeholder != null && placeholder.name == "Angler#" + other && placeholder.avatarUrl == null, "remote miss returns original default-name placeholder before HTTP completes"); Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && events.Count == 0 && wire.Requests.Count == requestStart + 1, "pending remote read shares one object and one request without early event"); if (wire.Requests.Count == requestStart + 1) { var body = JObject.Parse(wire.Requests[^1].Body); Expect(body.Properties().Count() == 1 && (string)body["PlayFabId"] == other, "existing request field carries target new UserID without extra identity aliases"); release.SetResult(await Json(new { code = 1, info = new { playFabId = other, displayName = "Remote", avatarUrl = "remote-avatar", level = 1 } })); await updated.Task.WaitAsync(TimeSpan.FromSeconds(5)); Expect(ReferenceEquals(placeholder, user.GetPlayInfo(other)) && placeholder.name == "Remote" && placeholder.avatarUrl == "remote-avatar" && events.Count == 1 && events[0].id == other, "successful HTTP updates the same consumer object and publishes one event"); await ExpectUnloaded(other, "SearchFriend nickname success does not populate asynchronous level cache"); updated = null; } else { release.SetResult(await Json(new { code = 0 })); updated = null; } // Completed in-memory responses exercise the actual async-void consumer's synchronous-completion path. foreach (var sample in new[] { (Body: "{\"code\":-1,\"info\":null}", Status: HttpStatusCode.OK, Success: false), (Body: "{\"code\":1,\"info\":null}", Status: HttpStatusCode.OK, Success: false), (Body: "null", Status: HttpStatusCode.OK, Success: false), (Body: "", Status: HttpStatusCode.OK, Success: false), (Body: "{invalid", Status: HttpStatusCode.OK, Success: false), (Body: "{}", Status: HttpStatusCode.InternalServerError, Success: false), (Body: "{\"code\":2,\"info\":{\"displayName\":\"\",\"avatarUrl\":null}}", Status: HttpStatusCode.OK, Success: true) }) { user.ClearPlayInfos(); events.Clear(); wire.Next = Task.FromResult(new HttpResponseMessage(sample.Status) { Content = new StringContent(sample.Body) }); int sent = wire.Requests.Count; var profile = user.GetPlayInfo(other); Expect(profile != null && profile.name == "Angler#" + other && profile.avatarUrl == null && ReferenceEquals(profile, user.GetPlayInfo(other)), "missing nickname or failed response preserves the cached placeholder"); Expect(wire.Requests.Count == sent + 1 && events.Count == (sample.Success ? 1 : 0), "code>0 and nonnull info alone determine successful profile publication: " + sample.Body); } Expect(Effects.SDK == sdk && user.IsAuthorized, "KV profiles and levels never invoke PF SDK or revoke on ordinary failure"); user.ClearPlayInfos(); } finally { wire.Active = false; wire.Next = null; } if (failures.Count != 0) throw new Exception("C3 profile migration gaps: " + string.Join("; ", failures)); Pass("C3-real-KV-profile-cache-reference-event-and-level-boundary-" + user.UserId); } }