保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
184 lines
12 KiB
C#
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);
|
|
}
|
|
}
|