226 lines
16 KiB
C#
226 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|