test(upserver): 归档存档迁移与玩法闭环回归工具
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using game.PlayerDataStorage;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
|
||||
internal static class AdvertCompatibilityTests
|
||||
{
|
||||
internal static void Run()
|
||||
{
|
||||
var snapshot = new PlayerKVSchemaSnapshot("nullable-advert-test", new[]{20001,30003}, new[]{1},
|
||||
new Dictionary<string,int>{{"None",0}}, CultureInfo.InvariantCulture, DateTime.UtcNow, 17);
|
||||
var raw = JObject.Parse(File.ReadAllText("CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/102.json"));
|
||||
var advert = JObject.Parse((string)raw["data"]["VIPAdPackData"]);
|
||||
int assertions=0;
|
||||
void Check(bool value,string name) { if(!value) throw new InvalidOperationException(name); assertions++; }
|
||||
PlayerKVShopState Decode(JObject value, out PlayerKVSchemaDiagnostics diagnostics)
|
||||
{
|
||||
diagnostics=new PlayerKVSchemaDiagnostics();
|
||||
return PlayerKVSaveSchema.DecodeShop(new Dictionary<string,string>{{"VIPAdPackData",value.ToString()}},snapshot,diagnostics);
|
||||
}
|
||||
var state=Decode(advert,out var errors);
|
||||
Check(state!=null && errors.TotalErrors==0 && state.Vip.Time==null,"real saved explicit null must remain null");
|
||||
foreach(var text in new[]{"", "2026-09-24T01:02:03Z"})
|
||||
{
|
||||
var value=(JObject)advert.DeepClone(); value["cdTime"]=text;
|
||||
state=Decode(value,out errors);
|
||||
Check(state!=null && state.Vip.Time==text && errors.TotalErrors==0,"string unchanged");
|
||||
}
|
||||
foreach(var bad in new JToken[]{new JValue(1),new JValue(true),new JObject(),new JArray()})
|
||||
{
|
||||
var value=(JObject)advert.DeepClone(); value["cdTime"]=bad;
|
||||
Check(Decode(value,out errors)==null && errors.TotalErrors==1,"invalid time type rejected");
|
||||
}
|
||||
var missing=(JObject)advert.DeepClone(); missing.Remove("cdTime");
|
||||
Check(Decode(missing,out errors)==null && errors.TotalErrors==1,"missing time rejected");
|
||||
foreach(var field in new[]{"ID","index","day","isOpen"})
|
||||
{
|
||||
var value=(JObject)advert.DeepClone(); value[field]=JValue.CreateNull();
|
||||
Check(Decode(value,out errors)==null && errors.TotalErrors==1,"other null rejected: "+field);
|
||||
value=(JObject)advert.DeepClone(); value.Remove(field);
|
||||
Check(Decode(value,out errors)==null && errors.TotalErrors==1,"other missing rejected: "+field);
|
||||
}
|
||||
Console.WriteLine($"PASS M04-advert-null-compatibility assertions={assertions} requests=0");
|
||||
}
|
||||
}
|
||||
225
CodexOutput/upserver/KVGameplayLoopTests/Preflight/Import101.cs
Normal file
225
CodexOutput/upserver/KVGameplayLoopTests/Preflight/Import101.cs
Normal file
@@ -0,0 +1,225 @@
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
internal static class Import101
|
||||
{
|
||||
internal const string Root = "CodexOutput/upserver/P2AlphaImport101/";
|
||||
internal const string Api = "http://s101.local:18080/";
|
||||
internal const string Version = "import101-fixed-20260928-v1";
|
||||
internal const string Source = "CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/101.json";
|
||||
const string SourceHash = "E6EB3E656B62A3535FDB8F0DC517321A4C3E32F3A8D95385A2B7D5C0AA8222EF";
|
||||
const string ValuesHash = "9A36C8A0EC16DE8ECD0DDC797CC763AB990411F9AAE4061CC61A9DA0A971DB68";
|
||||
const string ConfigHash = "3D39D21E49D7A0F25A71624FC3B222AAED1F8505E9D2AD2B177EEDE658241CE6";
|
||||
|
||||
// Internal paths/delegates are test seams; the CLI exposes no overrides.
|
||||
internal sealed class InputPaths
|
||||
{
|
||||
internal string SourcePath = Source;
|
||||
internal string ValuesPath = Root + "D0-source-values.json";
|
||||
internal string ConfigPath = Root + "D0-config-hashes.json";
|
||||
internal string ConfigRoot = ".";
|
||||
}
|
||||
internal sealed class Input
|
||||
{
|
||||
internal JObject Source;
|
||||
internal Dictionary<string, string> Raw;
|
||||
internal Dictionary<string, JArray> Tables;
|
||||
}
|
||||
|
||||
internal static string Hash(byte[] bytes) => Convert.ToHexString(SHA256.HashData(bytes));
|
||||
internal static string Hash(string value) => Hash(Encoding.UTF8.GetBytes(value));
|
||||
internal static JToken Parse(string text)
|
||||
{
|
||||
using var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None };
|
||||
var value = JToken.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
|
||||
if (reader.Read()) throw new InvalidDataException("Trailing JSON content.");
|
||||
return value;
|
||||
}
|
||||
static JToken CheckedFile(string path, string hash, long? length = null)
|
||||
{
|
||||
var bytes = File.ReadAllBytes(path);
|
||||
if (Hash(bytes) != hash || (length.HasValue && bytes.LongLength != length.Value))
|
||||
throw new InvalidDataException("Frozen input mismatch: " + path);
|
||||
return Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'));
|
||||
}
|
||||
internal static void Save(string path, object value)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value, Formatting.Indented));
|
||||
using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.Read);
|
||||
stream.Write(bytes); stream.Flush(true);
|
||||
}
|
||||
internal static Input LoadInputs(InputPaths paths)
|
||||
{
|
||||
var source = (JObject)CheckedFile(paths.SourcePath, SourceHash);
|
||||
var values = (JObject)CheckedFile(paths.ValuesPath, ValuesHash);
|
||||
var config = (JObject)CheckedFile(paths.ConfigPath, ConfigHash);
|
||||
if ((string)source["source"] != "PlayFab" || (string)source["titleId"] != "108EE8" ||
|
||||
(string)source["customId"] != "101" || source["playFabId"]?.Type != JTokenType.String ||
|
||||
string.IsNullOrEmpty((string)source["playFabId"]) || source["data"] is not JObject data ||
|
||||
data.Properties().Any(p => p.Value.Type != JTokenType.String))
|
||||
throw new InvalidDataException("Source identity or string data mismatch.");
|
||||
var raw = data.Properties().ToDictionary(p => p.Name, p => (string)p.Value, StringComparer.Ordinal);
|
||||
var entries = (JArray)values["entries"];
|
||||
if ((string)values["api"] != Api || (string)values["targetPlayerId"] != "101" ||
|
||||
(string)values["sourceSha256"] != SourceHash || (bool)values["transformValues"] ||
|
||||
(int)values["writeCount"] != 55 || raw.Count != 55 || entries.Count != 55 ||
|
||||
entries.Select(v => (string)v["key"]).Distinct(StringComparer.Ordinal).Count() != 55 ||
|
||||
entries.Any(v => !raw.TryGetValue((string)v["key"], out var value) ||
|
||||
Hash(value) != (string)v["valueSha256"] || Encoding.UTF8.GetByteCount(value) != (int)v["utf8Bytes"]))
|
||||
throw new InvalidDataException("55 frozen values mismatch.");
|
||||
if ((string)config["version"] != Version || (int)config["count"] != 7 || ((JArray)config["items"]).Count != 7)
|
||||
throw new InvalidDataException("Configuration version mismatch.");
|
||||
var tables = new Dictionary<string, JArray>(StringComparer.Ordinal);
|
||||
foreach (var item in (JArray)config["items"])
|
||||
{
|
||||
var relative = (string)item["path"];
|
||||
tables.Add(Path.GetFileNameWithoutExtension(relative), (JArray)CheckedFile(
|
||||
Path.Combine(paths.ConfigRoot, relative), (string)item["sha256"], (long)item["bytes"]));
|
||||
}
|
||||
return new Input { Source = source, Raw = raw, Tables = tables };
|
||||
}
|
||||
internal static PlayerKVSchemaResult ValidateCandidate(S101AccountSession session, Input input)
|
||||
{
|
||||
JArray Table(string name) => input.Tables[name];
|
||||
var global = Table("tbglobalconfig")[0];
|
||||
var snapshot = new PlayerKVSchemaSnapshot(Version, Table("tbroddata").Select(v => (int)v["ID"]), Table("tbmapdata").Select(v => (int)v["ID"]),
|
||||
Enum.GetNames<cfg.ConditionType>().ToDictionary(n => n, n => (int)Enum.Parse<cfg.ConditionType>(n)), CultureInfo.InvariantCulture,
|
||||
session.ServerTimeUtc, (int)global["InitWheelTicket"], Table("tbeventpackmanager").ToDictionary(v => (int)v["ID"], v => (int)v["PackType"]),
|
||||
Table("tbfishingevent").ToDictionary(v => (int)v["ID"], v => new PlayerKVEventConfig((int)v["RedirectID"],
|
||||
(string)v["TimeDefinition"]["$type"] == "LimitedTime" ? (string)v["TimeDefinition"]["EndTime"] : null)), (int)global["GlobalTimeOffset"],
|
||||
Table("tbfishdata").ToDictionary(v => (int)v["ID"], v => new PlayerKVFishConfig((int)v["ID"], (int)v["AreaID"], (int)v["Quality"], (int)v["FishCardID"], (float)v["BaseWeight"], (float)v["PointExtraParam"], (float)v["WeightRandom"])),
|
||||
Table("tbfishcard").ToDictionary(v => (int)v["ID"], v => new PlayerKVFishCardConfig((int)v["ID"], (int)v["MaxLevel"], v["CardsRequiredList"].ToObject<int[]>(), v["WeightMagList"].ToObject<float[]>())));
|
||||
var prepared = new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None, "101", 1, ownerId: "import101-candidate", data: input.Raw);
|
||||
var account = new PlayerKVAccountDescriptor("101", 1, session.InternalId, false, session.CreateTimeUtc, session.ServerTimeUtc);
|
||||
var identity = new PlayerKVIdentitySnapshot((string)input.Source["playFabId"], false, PlayerKVIdentityChannel.none, Array.Empty<PlayerKVIdentityChannel>());
|
||||
return PlayerKVSaveSchema.Decode(prepared, account, "101", 1, true, snapshot, Version, identity);
|
||||
}
|
||||
internal static void CheckWindow(JObject window)
|
||||
{
|
||||
DateTimeOffset ReadUtc(string key)
|
||||
{
|
||||
var text = window[key]?.Type == JTokenType.String ? (string)window[key] : null;
|
||||
if (text == null || !text.EndsWith("Z", StringComparison.Ordinal) || !DateTimeOffset.TryParse(text,
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var time))
|
||||
throw new InvalidDataException("Window UTC missing: " + key);
|
||||
return time;
|
||||
}
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
if ((string)window["playerId"] != "101" || (string)window["api"] != Api ||
|
||||
window["noOtherClientsDeclared"]?.Type != JTokenType.Boolean || !(bool)window["noOtherClientsDeclared"] ||
|
||||
window["implementationReviewApproved"]?.Type != JTokenType.Boolean || !(bool)window["implementationReviewApproved"] ||
|
||||
string.IsNullOrWhiteSpace((string)window["windowId"]) || string.IsNullOrWhiteSpace((string)window["operator"]) ||
|
||||
window["verificationMethods"] is not JArray methods || methods.Count == 0 ||
|
||||
methods.Any(v => v.Type != JTokenType.String || string.IsNullOrWhiteSpace((string)v)) ||
|
||||
ReadUtc("startUtc") > now || ReadUtc("expiresUtc") <= now)
|
||||
throw new InvalidOperationException("Reviewed stop-write window is not active.");
|
||||
}
|
||||
internal static async Task Run()
|
||||
{
|
||||
if (!string.Equals(Path.GetFullPath("."), Path.GetFullPath("C:/projects/ft"), StringComparison.OrdinalIgnoreCase))
|
||||
throw new InvalidOperationException("Run from fixed project root.");
|
||||
var window = (JObject)Parse(File.ReadAllText(Root + "D3-window.json"));
|
||||
if (window["testOnly"]?.Value<bool>() == true) throw new InvalidOperationException("Test window is not a live window.");
|
||||
await RunCore(Root, new InputPaths(), window,
|
||||
jar => new HttpClientHandler { CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }, ValidateCandidate, Save);
|
||||
}
|
||||
internal static async Task<string> RunCore(string evidenceRoot, InputPaths paths, JObject window,
|
||||
Func<CookieContainer, HttpMessageHandler> handlers,
|
||||
Func<S101AccountSession, Input, PlayerKVSchemaResult> validate, Action<string, object> save)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(handlers); ArgumentNullException.ThrowIfNull(validate); ArgumentNullException.ThrowIfNull(save);
|
||||
var claim = Path.GetFullPath(Path.Combine(evidenceRoot, "import-101.claimed"));
|
||||
if (File.Exists(claim)) throw new InvalidOperationException("Import already attempted; never replay or delete claim.");
|
||||
CheckWindow(window);
|
||||
var input = LoadInputs(paths);
|
||||
var folder = Path.GetFullPath(Path.Combine(evidenceRoot, "runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ") + "-" + Guid.NewGuid().ToString("N")));
|
||||
if (Directory.Exists(folder)) throw new IOException("Evidence directory exists.");
|
||||
Directory.CreateDirectory(folder);
|
||||
int loginCalls = 0, getCalls = 0, setCalls = 0;
|
||||
string stage = "prelogin";
|
||||
var localErrors = new List<string>();
|
||||
void SavePost(string name, object value)
|
||||
{
|
||||
try { save(Path.Combine(folder, name), value); }
|
||||
catch (Exception e) { localErrors.Add(name + ": " + e.GetType().Name + ": " + e.Message); }
|
||||
}
|
||||
try
|
||||
{
|
||||
save(Path.Combine(folder, "start.json"), new { playerId = "101", api = Api, at = DateTime.UtcNow, window, sourceHash = SourceHash, configVersion = Version });
|
||||
using var session = new S101AccountSession(new Uri(Api), handlers);
|
||||
stage = "login"; loginCalls++;
|
||||
var login = await session.LoginAsync("101");
|
||||
save(Path.Combine(folder, "account.json"), new { login.Success, login.Error, login.StatusCode, session.PlayerId,
|
||||
session.InternalId, session.NewlyCreated, session.CreateTimeUtc, session.ServerTimeUtc, localUtc = DateTime.UtcNow });
|
||||
if (!login.Success || session.NewlyCreated || session.PlayerId != "101") throw new InvalidOperationException("Existing 101 login required; login may have server side effects.");
|
||||
using var http = new HttpClient(handlers(session.Cookies)) { Timeout = TimeSpan.FromSeconds(20) };
|
||||
var kv = new PlayerKVClient(http, session.ApiBaseUri);
|
||||
stage = "before"; getCalls++;
|
||||
var before = await kv.GetAsync(null);
|
||||
if (!before.Success) throw new InvalidOperationException("Before Get failed: " + before.Error);
|
||||
save(Path.Combine(folder, "before.json"), new { playerId = "101", api = Api, at = DateTime.UtcNow, data = before.Data });
|
||||
if (before.Data.Count != 0) throw new InvalidOperationException("101 target is not empty; refusing overwrite.");
|
||||
stage = "candidate";
|
||||
var decoded = validate(session, input);
|
||||
save(Path.Combine(folder, "candidate-schema.json"), new { candidateOnly = true, serverTimeUtc = session.ServerTimeUtc,
|
||||
configVersion = Version, verdict = decoded.Verdict.ToString(), errors = decoded.Diagnostics.TotalErrors, details = decoded.Diagnostics.Details });
|
||||
if (decoded.Verdict != PlayerKVSchemaVerdict.Valid) throw new InvalidOperationException("Candidate is not Valid.");
|
||||
CheckWindow(window);
|
||||
stage = "claim";
|
||||
save(claim, new { playerId = "101", api = Api, folder, sourceHash = SourceHash, keyCount = 55,
|
||||
configVersion = Version, at = DateTime.UtcNow, windowId = (string)window["windowId"] });
|
||||
// The durable claim consumes this attempt even if the process dies before transport.
|
||||
CheckWindow(window);
|
||||
PlayerKVResult set = null, after = null;
|
||||
string setException = null, getException = null;
|
||||
stage = "set"; setCalls++;
|
||||
try { set = await kv.SetAsync(input.Raw); }
|
||||
catch (Exception e) { setException = e.GetType().Name + ": " + e.Message; }
|
||||
finally
|
||||
{
|
||||
SavePost("set-result.json", new { result = set, exception = setException, mayHaveWritten = true });
|
||||
// A local result-log failure must never bypass this one observation.
|
||||
stage = "after"; getCalls++;
|
||||
try { after = await kv.GetAsync(null); }
|
||||
catch (Exception e) { getException = e.GetType().Name + ": " + e.Message; }
|
||||
if (after?.Success == true)
|
||||
SavePost("after.json", new { playerId = "101", api = Api, at = DateTime.UtcNow, data = after.Data });
|
||||
else SavePost("get-failure.json", new { error = after?.Error.ToString(), after?.StatusCode, exception = getException });
|
||||
}
|
||||
var matrix = input.Raw.OrderBy(p => p.Key, StringComparer.Ordinal).Select(p => new {
|
||||
key = p.Key, present = after?.Data?.ContainsKey(p.Key) == true,
|
||||
equal = after?.Data?.TryGetValue(p.Key, out var value) == true && string.Equals(value, p.Value, StringComparison.Ordinal),
|
||||
expectedHash = Hash(p.Value), expectedBytes = Encoding.UTF8.GetByteCount(p.Value),
|
||||
actualHash = after?.Data?.TryGetValue(p.Key, out var actual) == true ? Hash(actual) : null,
|
||||
actualBytes = after?.Data?.TryGetValue(p.Key, out var bytesValue) == true ? (int?)Encoding.UTF8.GetByteCount(bytesValue) : null
|
||||
}).ToArray();
|
||||
var extra = after?.Data?.Keys.Except(input.Raw.Keys, StringComparer.Ordinal).OrderBy(k => k, StringComparer.Ordinal).ToArray() ?? Array.Empty<string>();
|
||||
bool windowActive = true;
|
||||
try { CheckWindow(window); } catch (Exception e) { windowActive = false; localErrors.Add(e.Message); }
|
||||
bool valuesEqual = after?.Success == true && after.Data.Count == 55 && matrix.All(v => v.equal) && extra.Length == 0;
|
||||
stage = "verification";
|
||||
SavePost("verification.json", new { valuesEqual, matrix, extra, adapterKeyOrder = after?.Data?.Keys.ToArray(),
|
||||
orderNote = "Client dictionary enumeration, not raw HTTP order or concurrency proof.",
|
||||
setSuccess = set?.Success == true, setException, windowActive, localErrors = localErrors.ToArray(),
|
||||
loginCalls, getCalls, setCalls, endUtc = DateTime.UtcNow });
|
||||
if (set?.Success != true || !valuesEqual || !windowActive || localErrors.Count != 0)
|
||||
throw new InvalidOperationException("Import needs manual review; never resend, delete or start game. " + string.Join("; ", localErrors));
|
||||
Console.WriteLine(JsonConvert.SerializeObject(new { success = true, folder, loginCalls, getCalls, setCalls, kvDelete = 0, pf = 0 }));
|
||||
return folder;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
SavePost("failure.json", new { stage, error = e.GetType().Name + ": " + e.Message, loginCalls, getCalls, setCalls,
|
||||
mayHaveWritten = setCalls > 0, localErrors = localErrors.ToArray(), endUtc = DateTime.UtcNow });
|
||||
Console.Error.WriteLine(JsonConvert.SerializeObject(new { success = false, folder, stage, loginCalls, getCalls, setCalls,
|
||||
mayHaveWritten = setCalls > 0, error = e.Message, localErrors }));
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
|
||||
internal static class Import101Tests
|
||||
{
|
||||
static int assertions;
|
||||
static readonly List<string> assertionFailures = new();
|
||||
static void Check(bool condition, string message)
|
||||
{
|
||||
assertions++;
|
||||
if (!condition)
|
||||
{
|
||||
assertionFailures.Add(message);
|
||||
throw new InvalidOperationException("ASSERT: " + message);
|
||||
}
|
||||
}
|
||||
static JObject Window() => new JObject {
|
||||
["playerId"] = "101", ["api"] = Import101.Api, ["windowId"] = "offline-fixture",
|
||||
["operator"] = "Import101Tests", ["testOnly"] = true,
|
||||
["startUtc"] = DateTime.UtcNow.AddMinutes(-1).ToString("o"),
|
||||
["expiresUtc"] = DateTime.UtcNow.AddHours(1).ToString("o"),
|
||||
["verificationMethods"] = new JArray("mock only; no real account window"),
|
||||
["noOtherClientsDeclared"] = true, ["implementationReviewApproved"] = true
|
||||
};
|
||||
sealed class Mock
|
||||
{
|
||||
internal string Fault;
|
||||
internal int Login, Get, Set;
|
||||
internal JObject Window;
|
||||
internal readonly Dictionary<string, string> Expected;
|
||||
internal Dictionary<string, string> Stored = new(StringComparer.Ordinal);
|
||||
internal string[] ReturnedOrder;
|
||||
internal Mock(Dictionary<string, string> expected) { Expected = expected; }
|
||||
internal HttpMessageHandler Handler(CookieContainer jar) => new HandlerImpl(this, jar);
|
||||
sealed class HandlerImpl : HttpMessageHandler
|
||||
{
|
||||
readonly Mock mock;
|
||||
readonly CookieContainer jar;
|
||||
internal HandlerImpl(Mock mock, CookieContainer jar) { this.mock = mock; this.jar = jar; }
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token)
|
||||
{
|
||||
Check(request.Method == HttpMethod.Post, "only POST");
|
||||
Check(request.RequestUri.AbsoluteUri.StartsWith(Import101.Api, StringComparison.Ordinal), "fixed API");
|
||||
var body = JObject.Parse(await request.Content.ReadAsStringAsync(token));
|
||||
HttpResponseMessage Reply(object value, HttpStatusCode status = HttpStatusCode.OK) => new(status) {
|
||||
Content = new StringContent(JsonConvert.SerializeObject(value), Encoding.UTF8, "application/json")
|
||||
};
|
||||
switch (request.RequestUri.AbsolutePath)
|
||||
{
|
||||
case "/account/Login":
|
||||
mock.Login++;
|
||||
Check(mock.Login == 1 && mock.Get == 0 && mock.Set == 0, "one initial login");
|
||||
Check((string)body["playerId"] == "101" && body.Count == 1, "only 101");
|
||||
jar.Add(new Uri(Import101.Api), new Cookie("session", "offline", "/"));
|
||||
if (mock.Fault == "login-http") return Reply(new { }, HttpStatusCode.Forbidden);
|
||||
if (mock.Fault == "login-protocol") return Reply(new { });
|
||||
return Reply(new { state = 0, internalId = 101, newCreate = mock.Fault == "new-account",
|
||||
createTime = "2026-09-24T00:00:00Z", svrTime = "2026-09-28T07:30:00Z" });
|
||||
case "/playerdata/Get":
|
||||
mock.Get++;
|
||||
Check(mock.Login == 1 && mock.Get <= 2 && (mock.Get == 1 ? mock.Set == 0 : mock.Set == 1), "Get order/bound");
|
||||
Check(body.Count == 0, "full Get");
|
||||
if (mock.Get == 1)
|
||||
{
|
||||
if (mock.Fault == "before-http") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
|
||||
if (mock.Fault == "nonempty") return Reply(new { data = new { existing = "untouched" } });
|
||||
return Reply(new { data = mock.Stored });
|
||||
}
|
||||
if (mock.Fault == "after-http") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
|
||||
if (mock.Fault == "after-throw") throw new InvalidOperationException("mock after exception");
|
||||
var after = mock.Stored.Reverse().ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
||||
if (mock.Fault == "missing") after.Remove(after.Keys.First());
|
||||
if (mock.Fault == "extra") after.Add("test-extra", "x");
|
||||
if (mock.Fault == "changed") after[after.Keys.First()] += " ";
|
||||
if (mock.Fault == "window-ended") mock.Window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
|
||||
mock.ReturnedOrder = after.Keys.ToArray();
|
||||
return Reply(new { data = after });
|
||||
case "/playerdata/Set":
|
||||
mock.Set++;
|
||||
Check(mock.Login == 1 && mock.Get == 1 && mock.Set == 1, "one Set after baseline");
|
||||
Check(body.Count == 1 && body["data"] is JObject, "Set data only");
|
||||
var data = (JObject)body["data"];
|
||||
Check(data.Count == 55, "55 entries incl unknown keys");
|
||||
foreach (var p in mock.Expected)
|
||||
Check(data[p.Key]?.Type == JTokenType.String && (string)data[p.Key] == p.Value, "original string " + p.Key);
|
||||
mock.Stored = data.Properties().ToDictionary(p => p.Name, p => (string)p.Value, StringComparer.Ordinal);
|
||||
if (mock.Fault == "set-throw") throw new InvalidOperationException("mock uncaught transport exception");
|
||||
if (mock.Fault == "unknown") return Reply(new { }, HttpStatusCode.ServiceUnavailable);
|
||||
return Reply(new { });
|
||||
default: throw new InvalidOperationException("Forbidden request (including PF/102/Delete): " + request.RequestUri);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
internal static async Task Run()
|
||||
{
|
||||
assertions = 0;
|
||||
assertionFailures.Clear();
|
||||
var start = DateTime.UtcNow;
|
||||
var suite = Path.GetFullPath(Import101.Root + "D2-tests/" + start.ToString("yyyyMMddTHHmmssZ") + "-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(suite);
|
||||
var original = Import101.LoadInputs(new Import101.InputPaths());
|
||||
var cases = new List<object>();
|
||||
int realDecodes = 0;
|
||||
var names = new[] { "source-hash", "manifest-missing-key", "manifest-duplicate-key", "manifest-value-change", "config-manifest-version",
|
||||
"config-hash", "source-json-duplicate", "source-json-trailing", "window-not-approved", "window-expired", "null-handler",
|
||||
"login-http", "login-protocol", "new-account", "before-http", "nonempty", "candidate-invalid",
|
||||
"save-before", "save-candidate", "claim-existing", "claim-failure", "claim-partial", "claim-race", "window-after-claim", "save-account", "success-real-decode",
|
||||
"unknown", "set-throw", "save-set-result", "after-http", "after-throw", "missing", "extra", "changed", "save-after",
|
||||
"save-verification", "window-ended" };
|
||||
foreach (var name in names)
|
||||
{
|
||||
var evidence = Path.Combine(suite, name);
|
||||
Directory.CreateDirectory(evidence);
|
||||
var paths = new Import101.InputPaths();
|
||||
var window = Window();
|
||||
var mock = new Mock(original.Raw) { Fault = name, Window = window };
|
||||
var claim = Path.Combine(evidence, "import-101.claimed");
|
||||
string previousClaim = null;
|
||||
if (name == "claim-existing") { previousClaim = "existing-attempt"; File.WriteAllText(claim, previousClaim); }
|
||||
if (name == "source-hash") { paths.SourcePath = Path.Combine(evidence, "source.json"); File.WriteAllText(paths.SourcePath, "{}"); }
|
||||
if (name.StartsWith("manifest-", StringComparison.Ordinal))
|
||||
{
|
||||
var values = (JObject)Import101.Parse(File.ReadAllText(paths.ValuesPath));
|
||||
var entries = (JArray)values["entries"];
|
||||
if (name == "manifest-missing-key") entries.RemoveAt(0);
|
||||
if (name == "manifest-duplicate-key") entries.Add(entries[0].DeepClone());
|
||||
if (name == "manifest-value-change") entries[0]["valueSha256"] = "changed";
|
||||
paths.ValuesPath = Path.Combine(evidence, "values.json"); File.WriteAllText(paths.ValuesPath, values.ToString());
|
||||
}
|
||||
if (name == "config-manifest-version")
|
||||
{
|
||||
var cfg = (JObject)Import101.Parse(File.ReadAllText(paths.ConfigPath)); cfg["version"] = "wrong";
|
||||
paths.ConfigPath = Path.Combine(evidence, "config.json"); File.WriteAllText(paths.ConfigPath, cfg.ToString());
|
||||
}
|
||||
if (name == "config-hash")
|
||||
{
|
||||
paths.ConfigRoot = Path.Combine(evidence, "config-copy"); Directory.CreateDirectory(Path.Combine(paths.ConfigRoot, "cfgData"));
|
||||
var cfg = (JObject)Import101.Parse(File.ReadAllText(paths.ConfigPath));
|
||||
foreach (var item in (JArray)cfg["items"])
|
||||
File.Copy((string)item["path"], Path.Combine(paths.ConfigRoot, (string)item["path"]));
|
||||
File.AppendAllText(Path.Combine(paths.ConfigRoot, (string)cfg["items"][0]["path"]), " ");
|
||||
}
|
||||
if (name == "window-not-approved") window["implementationReviewApproved"] = false;
|
||||
if (name == "window-expired") window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
|
||||
Action<string, object> save = (path, value) => {
|
||||
var file = Path.GetFileName(path);
|
||||
if ((name == "save-before" && file == "before.json") || (name == "save-candidate" && file == "candidate-schema.json") ||
|
||||
(name == "save-set-result" && file == "set-result.json") || (name == "save-after" && file == "after.json") ||
|
||||
(name == "save-verification" && file == "verification.json") || (name == "save-account" && file == "account.json") ||
|
||||
(name == "claim-failure" && path == claim))
|
||||
throw new IOException("injected persistence failure: " + file);
|
||||
if (name == "claim-partial" && path == claim) { File.WriteAllText(path, "partial-claim"); throw new IOException("injected partial claim"); }
|
||||
if (name == "claim-race" && path == claim) File.WriteAllText(path, "concurrent-claim");
|
||||
Import101.Save(path, value);
|
||||
if (name == "window-after-claim" && path == claim) window["expiresUtc"] = DateTime.UtcNow.AddSeconds(-1).ToString("o");
|
||||
};
|
||||
Func<S101AccountSession, Import101.Input, PlayerKVSchemaResult> validate = (session, input) => {
|
||||
if (name == "candidate-invalid") return new PlayerKVSchemaResult(PlayerKVSchemaVerdict.Malformed, new PlayerKVSchemaDiagnostics());
|
||||
if (name == "success-real-decode")
|
||||
{
|
||||
realDecodes++;
|
||||
Check(session.ServerTimeUtc == new DateTime(2026, 9, 28, 7, 30, 0, DateTimeKind.Utc), "candidate uses mock server time");
|
||||
return Import101.ValidateCandidate(session, input);
|
||||
}
|
||||
return new PlayerKVSchemaResult(PlayerKVSchemaVerdict.Valid, new PlayerKVSchemaDiagnostics());
|
||||
};
|
||||
int beforeAssertions = assertions;
|
||||
string folder = null, error = null;
|
||||
try
|
||||
{
|
||||
if (name.StartsWith("source-json-", StringComparison.Ordinal))
|
||||
{
|
||||
Import101.Parse(name == "source-json-duplicate" ? "{\"a\":1,\"a\":2}" : "{} {}");
|
||||
}
|
||||
else folder = await Import101.RunCore(evidence, paths, window, name == "null-handler" ? null : mock.Handler, validate, save);
|
||||
}
|
||||
catch (Exception e) { error = e.GetType().Name + ": " + e.Message; }
|
||||
// Expected importer exceptions must not hide an assertion thrown inside a mock handler.
|
||||
Check(assertionFailures.Count == 0, name + " no swallowed mock assertion");
|
||||
bool success = name == "success-real-decode";
|
||||
Check((error == null) == success, name + " expected outcome: " + error);
|
||||
int expectedSet = Array.IndexOf(names, name) >= Array.IndexOf(names, "success-real-decode") ? 1 : 0;
|
||||
Check(mock.Set == expectedSet, name + " Set bound");
|
||||
if (expectedSet == 1)
|
||||
{
|
||||
Check(mock.Login == 1 && mock.Get == 2, name + " one observation even on failure");
|
||||
Check(File.Exists(claim), name + " durable claim retained");
|
||||
var claimHash = Import101.Hash(File.ReadAllBytes(claim));
|
||||
int total = mock.Login + mock.Get + mock.Set;
|
||||
bool rejected = false;
|
||||
try { await Import101.RunCore(evidence, paths, Window(), mock.Handler, validate, save); }
|
||||
catch (InvalidOperationException) { rejected = true; }
|
||||
Check(rejected && total == mock.Login + mock.Get + mock.Set, name + " replay rejected before network");
|
||||
Check(Import101.Hash(File.ReadAllBytes(claim)) == claimHash, name + " replay claim unchanged");
|
||||
}
|
||||
if (name.StartsWith("source-", StringComparison.Ordinal) || name.StartsWith("manifest-", StringComparison.Ordinal) ||
|
||||
name.StartsWith("config-", StringComparison.Ordinal) || name is "window-not-approved" or "window-expired" ||
|
||||
name == "null-handler" || name == "claim-existing") Check(mock.Login == 0 && mock.Get == 0, name + " local gate");
|
||||
if (name is "login-http" or "login-protocol" or "new-account") Check(mock.Get == 0, name + " no Get after login refusal");
|
||||
if (name == "claim-existing") Check(File.ReadAllText(claim) == previousClaim, "existing claim byte content");
|
||||
if (name == "claim-partial") Check(File.ReadAllText(claim) == "partial-claim", "partial claim retained");
|
||||
if (name == "claim-race") Check(File.ReadAllText(claim) == "concurrent-claim", "competing claim not overwritten");
|
||||
if (name == "window-after-claim") Check(File.Exists(claim) && mock.Get == 1, "expired window consumes claim but never Set");
|
||||
if (name == "save-account") Check(mock.Login == 1 && mock.Get == 0, "account evidence before Get");
|
||||
if (name is "candidate-invalid" or "save-before" or "save-candidate" or "claim-failure") Check(!File.Exists(claim), name + " no completed claim");
|
||||
var runs = Path.Combine(evidence, "runs");
|
||||
var actualFolder = folder ?? (Directory.Exists(runs) ? Directory.GetDirectories(runs).Single() : null);
|
||||
if (name == "save-set-result") Check(File.Exists(Path.Combine(actualFolder, "after.json")), "after persisted despite set log failure");
|
||||
if (success)
|
||||
{
|
||||
var result = (JObject)Import101.Parse(File.ReadAllText(Path.Combine(folder, "verification.json")));
|
||||
Check((bool)result["valuesEqual"] && (bool)result["setSuccess"], "positive exact readback");
|
||||
Check(result["adapterKeyOrder"].Values<string>().SequenceEqual(mock.ReturnedOrder), "unmodified adapter enumeration order");
|
||||
var decoded = (JObject)Import101.Parse(File.ReadAllText(Path.Combine(folder, "candidate-schema.json")));
|
||||
Check((string)decoded["verdict"] == "Valid" && (int)decoded["errors"] == 0, "real 55-key candidate valid");
|
||||
}
|
||||
cases.Add(new { name, pass = true, assertions = assertions - beforeAssertions, mock.Login, mock.Get, mock.Set,
|
||||
evidence, error, realCandidateDecode = success, formalRequests = 0 });
|
||||
Console.WriteLine("PASS " + name);
|
||||
}
|
||||
Check(realDecodes == 1, "real candidate exercised once");
|
||||
Import101.Save(Path.Combine(suite, "result.json"), new { success = true, cases, assertions, realDecodes,
|
||||
startUtc = start, endUtc = DateTime.UtcNow, formalRequests = 0, note = "All handlers are in-memory; failures other than success case are injected." });
|
||||
Console.WriteLine(JsonConvert.SerializeObject(new { success = true, cases = cases.Count, assertions, realDecodes, formalRequests = 0, suite }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
internal static class Import102
|
||||
{
|
||||
const string Root="CodexOutput/upserver/KVGameplayLoopTests/";
|
||||
const string Api="http://s101.local:18080/";
|
||||
static string Hash(byte[] value)=>Convert.ToHexString(SHA256.HashData(value));
|
||||
static string Hash(string value)=>Hash(Encoding.UTF8.GetBytes(value));
|
||||
static JToken Read(string path)
|
||||
{
|
||||
using var reader=new JsonTextReader(new StringReader(File.ReadAllText(path))){DateParseHandling=DateParseHandling.None};
|
||||
return JToken.ReadFrom(reader);
|
||||
}
|
||||
static void Save(string path,object value)
|
||||
{
|
||||
var bytes=Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(value,Formatting.Indented));
|
||||
using var file=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read);
|
||||
file.Write(bytes); file.Flush(true);
|
||||
}
|
||||
static PlayerKVSchemaSnapshot Snapshot(DateTime now)
|
||||
{
|
||||
JArray Table(string name)=>(JArray)Read("cfgData/"+name+".json");
|
||||
var global=Table("tbglobalconfig")[0];
|
||||
return new PlayerKVSchemaSnapshot("kv-loop",Table("tbroddata").Select(v=>(int)v["ID"]),Table("tbmapdata").Select(v=>(int)v["ID"]),
|
||||
Enum.GetNames<cfg.ConditionType>().ToDictionary(n=>n,n=>(int)Enum.Parse<cfg.ConditionType>(n)),CultureInfo.InvariantCulture,
|
||||
now,(int)global["InitWheelTicket"],Table("tbeventpackmanager").ToDictionary(v=>(int)v["ID"],v=>(int)v["PackType"]),
|
||||
Table("tbfishingevent").ToDictionary(v=>(int)v["ID"],v=>new PlayerKVEventConfig((int)v["RedirectID"],
|
||||
(string)v["TimeDefinition"]["$type"]=="LimitedTime"?(string)v["TimeDefinition"]["EndTime"]:null)),(int)global["GlobalTimeOffset"],
|
||||
Table("tbfishdata").ToDictionary(v=>(int)v["ID"],v=>new PlayerKVFishConfig((int)v["ID"],(int)v["AreaID"],(int)v["Quality"],(int)v["FishCardID"],(float)v["BaseWeight"],(float)v["PointExtraParam"],(float)v["WeightRandom"])),
|
||||
Table("tbfishcard").ToDictionary(v=>(int)v["ID"],v=>new PlayerKVFishCardConfig((int)v["ID"],(int)v["MaxLevel"],v["CardsRequiredList"].ToObject<int[]>(),v["WeightMagList"].ToObject<float[]>())));
|
||||
}
|
||||
internal static async Task Run()
|
||||
{
|
||||
var proposal=Read(Root+"102-import-proposal.json");
|
||||
if((string)proposal["status"]!="proposal-not-executed" || (string)proposal["api"]!=Api ||
|
||||
(string)proposal["playerId"]!="102" || (bool)proposal["transformValues"] || (int)proposal["writeCount"]!=45)
|
||||
throw new InvalidOperationException("Approved proposal mismatch.");
|
||||
var sourcePath=(string)proposal["source"];
|
||||
if(Hash(File.ReadAllBytes(sourcePath))!="827F36084648449CEFAFB96B3CE62093DF70B8A87E33A526DA5B03A9C135A377")
|
||||
throw new InvalidOperationException("Original source changed.");
|
||||
var source=Read(sourcePath);
|
||||
if((string)source["customId"]!="102" || (string)source["titleId"]!="108EE8" || (string)source["source"]!="PlayFab")
|
||||
throw new InvalidOperationException("Source identity mismatch.");
|
||||
var raw=source["data"].ToObject<Dictionary<string,string>>();
|
||||
var entries=(JArray)proposal["entries"];
|
||||
if(raw.Count!=45 || entries.Count!=45 || entries.Select(v=>(string)v["key"]).Distinct().Count()!=45 ||
|
||||
entries.Any(v=>!raw.TryGetValue((string)v["key"],out var value) || value==null || Hash(value)!=(string)v["valueSha256"] || Encoding.UTF8.GetByteCount(value)!=(int)v["utf8Bytes"]))
|
||||
throw new InvalidOperationException("45 original values differ from reviewed proposal.");
|
||||
var claim=Root+"import-102.claimed";
|
||||
if(File.Exists(claim)) throw new InvalidOperationException("Import already attempted; inspect evidence, never replay.");
|
||||
var folder=Path.GetFullPath(Root+"imports/"+DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ"));
|
||||
Directory.CreateDirectory(folder);
|
||||
using var session=new S101AccountSession(new Uri(Api));
|
||||
var login=await session.LoginAsync("102");
|
||||
if(!login.Success || session.NewlyCreated || session.PlayerId!="102") throw new InvalidOperationException("Existing 102 login failed: "+login.Error);
|
||||
Save(Path.Combine(folder,"account.json"),new{session.PlayerId,session.InternalId,session.CreateTimeUtc,session.ServerTimeUtc,session.NewlyCreated});
|
||||
using var http=new HttpClient(new HttpClientHandler{CookieContainer=session.Cookies,UseCookies=true,AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(20)};
|
||||
var kv=new PlayerKVClient(http,session.ApiBaseUri);
|
||||
var before=await kv.GetAsync(null);
|
||||
if(!before.Success) throw new InvalidOperationException("Fresh baseline Get failed: "+before.Error);
|
||||
Save(Path.Combine(folder,"before.json"),new{playerId="102",api=Api,at=DateTime.UtcNow,data=before.Data});
|
||||
if(before.Data.Count!=0) throw new InvalidOperationException("102 is no longer empty; not overwriting.");
|
||||
var snapshot=Snapshot(session.ServerTimeUtc);
|
||||
// Candidate validation only: real account/time, no game handle, no claim that these are existing KV values.
|
||||
var candidate=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,"102",1,ownerId:"preimport-candidate",data:raw);
|
||||
var account=new PlayerKVAccountDescriptor("102",1,session.InternalId,false,session.CreateTimeUtc,session.ServerTimeUtc);
|
||||
var identity=new PlayerKVIdentitySnapshot((string)source["playFabId"],false,PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
|
||||
var decoded=PlayerKVSaveSchema.Decode(candidate,account,"102",1,true,snapshot,snapshot.Version,identity);
|
||||
Save(Path.Combine(folder,"candidate-schema.json"),new{candidateOnly=true,realAccountAndServerTime=true,verdict=decoded.Verdict.ToString(),errors=decoded.Diagnostics.TotalErrors,details=decoded.Diagnostics.Details});
|
||||
if(decoded.Verdict!=PlayerKVSchemaVerdict.Valid) throw new InvalidOperationException("Candidate not valid with real account/server time.");
|
||||
Save(claim,new{folder,playerId="102",api=Api,keys=raw.Keys.OrderBy(k=>k),at=DateTime.UtcNow});
|
||||
var set=await kv.SetAsync(raw); // One request only. No retry path.
|
||||
Save(Path.Combine(folder,"set-result.json"),set);
|
||||
var after=await kv.GetAsync(null); // Observe even if Set reports Unknown/failure.
|
||||
if(!after.Success) { Save(Path.Combine(folder,"get-failure.json"),new{after.Error,after.StatusCode}); throw new InvalidOperationException("Cannot confirm import; inspect, never retry."); }
|
||||
var path=Path.Combine(folder,"after.json");
|
||||
Save(path,new{playerId="102",api=Api,at=DateTime.UtcNow,data=after.Data});
|
||||
var matrix=raw.OrderBy(p=>p.Key).Select(p=>new{key=p.Key,present=after.Data.ContainsKey(p.Key),valueEqual=after.Data.TryGetValue(p.Key,out var value)&&value==p.Value,
|
||||
expectedHash=Hash(p.Value),actualHash=after.Data.TryGetValue(p.Key,out var actual)?Hash(actual):null}).ToArray();
|
||||
var extra=after.Data.Keys.Except(raw.Keys,StringComparer.Ordinal).OrderBy(k=>k).ToArray();
|
||||
var verified=matrix.All(v=>v.present&&v.valueEqual)&&extra.Length==0;
|
||||
Save(Path.Combine(folder,"verification.json"),new{verified,matrix,extra,set.Success,set.Error,set.MayHaveCommitted,afterBackup=path,sha256=Hash(File.ReadAllBytes(path)),formalRequests=4,setRequests=1,pfWrites=0});
|
||||
Console.WriteLine(JsonConvert.SerializeObject(new{verified,folder,afterBackup=path,keys=after.Data.Count,setRequests=1,formalRequests=4,pfWrites=0}));
|
||||
if(!verified || !set.Success) throw new InvalidOperationException("Import response or values require review; do not start game or resend.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
// Read existing accounts only. No account creation, PF UpdateUserData, or KV mutation entry exists.
|
||||
static class PFSourceBackup
|
||||
{
|
||||
public static async Task Run()
|
||||
{
|
||||
const string title="108EE8";
|
||||
const string localConfig="C:/Users/LB/AppData/LocalLow/Ark Game Limited (香港)/Fishing Travel/debugconfig.json";
|
||||
using var configured=JsonDocument.Parse(File.ReadAllText(localConfig));
|
||||
if(configured.RootElement.GetProperty("PlayFabTitle").GetString()!=title) throw new InvalidOperationException("Reviewed PF title changed.");
|
||||
var folder=Path.GetFullPath("CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/"+DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ"));
|
||||
Directory.CreateDirectory(folder);
|
||||
using var http=new HttpClient(new HttpClientHandler{AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(25)};
|
||||
foreach(var id in new[]{"101","102"})
|
||||
{
|
||||
var body=JsonSerializer.Serialize(new{TitleId=title,CustomId=id,CreateAccount=false,InfoRequestParameters=new{GetUserData=true}});
|
||||
using var response=await http.PostAsync("https://108EE8.playfabapi.com/Client/LoginWithCustomID",new StringContent(body,Encoding.UTF8,"application/json"));
|
||||
using var result=JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
if(!response.IsSuccessStatusCode || !result.RootElement.TryGetProperty("data",out var login))
|
||||
{
|
||||
var reason=result.RootElement.TryGetProperty("error",out var error)?error.GetString():"protocol";
|
||||
Console.WriteLine(JsonSerializer.Serialize(new{customId=id,titleId=title,success=false,http=(int)response.StatusCode,error=reason}));
|
||||
continue;
|
||||
}
|
||||
if(login.GetProperty("NewlyCreated").GetBoolean()) throw new InvalidOperationException("Unexpected account creation.");
|
||||
var data=login.GetProperty("InfoResultPayload").GetProperty("UserData").EnumerateObject()
|
||||
.ToDictionary(p=>p.Name,p=>p.Value.GetProperty("Value").GetString(),StringComparer.Ordinal);
|
||||
var bytes=Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new{source="PlayFab",titleId=title,customId=id,
|
||||
playFabId=login.GetProperty("PlayFabId").GetString(),at=DateTime.UtcNow,data},new JsonSerializerOptions{WriteIndented=true}));
|
||||
var path=Path.Combine(folder,id+".json");
|
||||
using(var stream=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read)) { stream.Write(bytes);stream.Flush(true); }
|
||||
var summary=new{customId=id,titleId=title,success=true,path,sha256=Convert.ToHexString(SHA256.HashData(bytes)),
|
||||
count=data.Count,keys=data.Keys.OrderBy(k=>k).ToArray(),hasMainSave=data.ContainsKey("NewPlayerDataDic"),pfWrites=0,kvWrites=0};
|
||||
File.WriteAllText(Path.Combine(folder,id+"-summary.json"),JsonSerializer.Serialize(summary,new JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine(JsonSerializer.Serialize(summary));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
|
||||
<RestoreConfigFile>$(MSBuildThisFileDirectory)../../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/PlayerData/*.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Services/Account/S101AccountSession.cs" />
|
||||
<Compile Include="../../../../Client/Assets/Scripts/Tables/ConditionType.cs" />
|
||||
<Reference Include="Newtonsoft.Json"><HintPath>../../../../Client/Library/PackageCache/com.unity.nuget.newtonsoft-json@3.2.1/Runtime/Newtonsoft.Json.dll</HintPath></Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,31 @@
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
if(args.Length==1 && args[0]=="--verify-import-101") { await Import101Tests.Run(); return; }
|
||||
if(args.Length==1 && args[0]=="--import-approved-101") { await Import101.Run(); return; }
|
||||
if(args.Length==1 && args[0]=="--verify-advert") { AdvertCompatibilityTests.Run(); return; }
|
||||
if(args.Length==1 && args[0]=="--import-approved-102") { await Import102.Run(); return; }
|
||||
if(args.Length==1 && args[0]=="--backup-pf-101-102") { await PFSourceBackup.Run(); return; }
|
||||
if(args.Length!=1 || args[0]!="--backup-101-102") throw new ArgumentException("Explicit backup command required.");
|
||||
const string api="http://s101.local:18080/";
|
||||
var folder=Path.GetFullPath("CodexOutput/upserver/KVGameplayLoopTests/backups/"+DateTime.UtcNow.ToString("yyyyMMddTHHmmssZ"));
|
||||
Directory.CreateDirectory(folder);
|
||||
foreach(var id in new[]{"101","102"})
|
||||
{
|
||||
using var session=new S101AccountSession(new Uri(api));
|
||||
var login=await session.LoginAsync(id);
|
||||
if(!login.Success || session.NewlyCreated || session.PlayerId!=id) throw new InvalidOperationException("Existing account login failed: "+id+" "+login.Error);
|
||||
using var http=new HttpClient(new HttpClientHandler{CookieContainer=session.Cookies,UseCookies=true,AllowAutoRedirect=false}){Timeout=TimeSpan.FromSeconds(20)};
|
||||
var kv=new PlayerKVClient(http,session.ApiBaseUri);
|
||||
var read=await kv.GetAsync(null);
|
||||
if(!read.Success) throw new InvalidOperationException("Read failed: "+id+" "+read.Error);
|
||||
var bytes=Encoding.UTF8.GetBytes(JsonSerializer.Serialize(new{playerId=id,api,at=DateTime.UtcNow,data=read.Data},new JsonSerializerOptions{WriteIndented=true}));
|
||||
var path=Path.Combine(folder,id+".json");
|
||||
using(var stream=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read)) { stream.Write(bytes); stream.Flush(true); }
|
||||
var summary=new{playerId=id,path,sha256=Convert.ToHexString(SHA256.HashData(bytes)),count=read.Data.Count,keys=read.Data.Keys.OrderBy(k=>k).ToArray(),hasMainSave=read.Data.ContainsKey("NewPlayerDataDic"),kvWrites=0};
|
||||
File.WriteAllText(Path.Combine(folder,id+"-summary.json"),JsonSerializer.Serialize(summary,new JsonSerializerOptions{WriteIndented=true}));
|
||||
Console.WriteLine(JsonSerializer.Serialize(summary));
|
||||
}
|
||||
Reference in New Issue
Block a user