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 Raw; internal Dictionary 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(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().ToDictionary(n => n, n => (int)Enum.Parse(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(), v["WeightMagList"].ToObject()))); 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()); 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() == 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 RunCore(string evidenceRoot, InputPaths paths, JObject window, Func handlers, Func validate, Action 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(); 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(); 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; } } }