Files
ft/CodexOutput/upserver/KVGameLoadTests/ServiceHost/C3ProfileCacheTests.cs
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

184 lines
12 KiB
C#

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<HttpResponseMessage> Next;
internal bool TryHandle(HttpRequestMessage request, out Task<HttpResponseMessage> 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<GetClubPlayInfoEvent>().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<string>();
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<GetClubPlayInfoEvent>();
TaskCompletionSource<bool> updated = null;
using var subscription = GContext.OnEvent<GetClubPlayInfoEvent>().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<HttpResponseMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
wire.Next = release.Task;
updated = new TaskCompletionSource<bool>(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);
}
}