Files
ft/CodexOutput/upserver/KVGameplayLoopTests/Preflight/Import101.cs

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;
}
}
}