test(upserver): 归档存档迁移与玩法闭环回归工具

This commit is contained in:
Liubing\LB
2026-09-28 18:16:14 +08:00
parent c4ae02def9
commit a6f5e107f8
232 changed files with 12903 additions and 0 deletions

View File

@@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework><ImplicitUsings>enable</ImplicitUsings>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems><UseAppHost>false</UseAppHost><DefineConstants>UNITY_EDITOR</DefineConstants>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup><Compile Include="Program.cs.txt"/><Compile Include="../../../../Client/Assets/Scripts/Stages/QuitAct.cs"/></ItemGroup>
</Project>

View File

@@ -0,0 +1,50 @@
using game;
using game.PlayerDataStorage;
int assertions=0, failures=0;
void Check(bool ok,string message){assertions++; if(!ok) throw new Exception(message);}
foreach(bool enabled in new[]{false,true}) foreach(var status in Enum.GetValues<PlayerKVShutdownStatus>())
{
try
{
PlayerKVGameplayLoop.Enabled=enabled; PlayerKVShutdownRegistry.Process.Status=status;
UnityEditor.EditorApplication.isPlaying=true; UnityEditor.EditorApplication.StopCount=0; RootCtx.DestroyCount=0;
var act=new Probe{DestoryRoot=Array.Empty<string>()}; act.Started();
Check(RootCtx.DestroyCount==1 && act.BaseStarted,"original cleanup and base callback kept");
#if UNITY_EDITOR && !SERVICE_HOST
if(enabled)
{
Check(act.Invoked==null,"test exit must not schedule Unload/PreBoot");
Check(UnityEditor.EditorApplication.StopCount==(status==PlayerKVShutdownStatus.Ready?1:0),"stop only after registry Ready");
Check(PlayerKVShutdownRegistry.Process.Status==status && PlayerKVGameplayLoop.Enabled,"barrier and once-per-process state retained");
}
else
#endif
{
Check(act.Invoked=="Unload" && act.Delay==2f,"ordinary exit preserves delayed Unload");
Check(UnityEditor.EditorApplication.StopCount==0,"ordinary exit never stops editor");
}
Console.WriteLine($"PASS enabled={enabled} status={status}");
}
catch(Exception e){failures++; Console.WriteLine($"FAIL enabled={enabled} status={status}: {e.Message}");}
}
Console.WriteLine($"RESULT actual-QuitAct groups=10 assertions={assertions} failures={failures} requests=0");
return failures==0?0:1;
class Probe:QuitAct { public void Started()=>base.OnStarted(); }
namespace game.PlayerDataStorage {
public enum PlayerKVShutdownStatus {Ready,Pending,Failed,Cancelled,Input}
public class PlayerKVShutdownRegistry {public static PlayerKVShutdownRegistry Process=new();public PlayerKVShutdownStatus Status;public Task<PlayerKVShutdownStatus> ObserveAsync(int ms)=>Task.FromResult(Status);}
public static class PlayerKVGameplayLoop {public static bool Enabled;public static Task CloseAsync()=>Task.CompletedTask;}
}
namespace UnityEditor {public static class EditorApplication {static bool playing;public static int StopCount;public static bool isPlaying{get=>playing;set{playing=value;if(!value)StopCount++;}}}}
namespace asap.core {public class InjectAttribute:Attribute{}public interface IConfig{void Rest();}}
namespace game {public interface IUserService{void Logout();}public class PlayFabUserService:IUserService {public void Logout(){} public Task CloseKVForValidationAsync()=>Task.CompletedTask;public Task ObserveKVQuitAsync()=>Task.CompletedTask;}public static class QuitStage{public static bool Logout;}}
namespace Game {public class AGameAct {public bool BaseStarted;public string Invoked;public float Delay;protected virtual void OnDestroy(){}protected virtual void OnStarted(){BaseStarted=true;}public virtual Task<bool> StartAsync()=>Task.FromResult(true);public void Invoke(string s,float n){Invoked=s;Delay=n;}}}
namespace GameCore {public static class Timer{public static void CancelAllRegisteredTimers(){}public static void Destroy(){}}}
namespace UnityEngine {public class GameObject{public static void Destroy(object x){}public static void DestroyImmediate(object x){}public static GameObject Find(string x)=>null;}public static class Debug{public static void LogException(Exception e){}public static void Log(object message){}}}
public interface IRTService{Task ReleaseAsync();}public interface IAdsService{void Release();}
public class SoundMgr{public void Release(){}}
public static class LoadingScreen{public static void Hide(){}}
public class UIManager{public static UIManager Instance=new();public UnityEngine.GameObject gameObject=new();}
public class GameEventMgr{public static GameEventMgr Instance=new();public void Clear(){}}
public class RedPointManager{public static RedPointManager Instance=new();public void Init(){}}
public static class RootCtx{public static int DestroyCount;public static void DestoryRootCtx(){DestroyCount++;}}

View File

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType><TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings><Nullable>disable</Nullable>
<DefineConstants>UNITY_EDITOR;SERVICE_HOST</DefineConstants>
<RestoreConfigFile>$(MSBuildThisFileDirectory)../MinimalLoginTests/NuGet.Config</RestoreConfigFile>
</PropertyGroup>
<ItemGroup>
<Compile Remove="Preflight/**/*.cs" />
<Compile Remove="ExitRegression/**/*.cs" />
<Compile Include="../../../Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs" Condition="Exists('../../../Client/Assets/Scripts/Services/UserService.KVGameplayLoop.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>

View File

@@ -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");
}
}

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

View File

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

View File

@@ -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.");
}
}

View File

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

View File

@@ -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>

View File

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

View File

@@ -0,0 +1,40 @@
using System.Reflection;
using System.Security.Cryptography;
using Newtonsoft.Json;
var root=Path.GetFullPath(Path.Combine("CodexOutput/upserver/KVGameplayLoopTests/runs",Guid.NewGuid().ToString("N")));
Directory.CreateDirectory(root);
int assertions=0;
void Check(bool yes,string why){assertions++;if(!yes)throw new Exception(why);}
try {
var type=Assembly.GetExecutingAssembly().GetType("game.PlayerDataStorage.PlayerKVLoopPermit");
Check(type!=null,"Missing approved loop permit validation");
object Load(string file)=>type.GetMethod("Read",BindingFlags.Static|BindingFlags.NonPublic).Invoke(null,new object[]{file});
void Reject(string file,string why){bool bad=false;try{Load(file);}catch(TargetInvocationException e){bad=e.InnerException is InvalidOperationException;}Check(bad,why);}
var backup=Path.Combine(root,"backup.json");File.WriteAllText(backup,JsonConvert.SerializeObject(new{playerId="101",api="http://s101.local:18080/",data=new Dictionary<string,string>{{"NewPlayerDataDic","{}"},{"k","v"}}}));
var digest=Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(backup)));
var permit=Path.Combine(root,"permit.json");
void Put(string id="101",string api="http://s101.local:18080/",string hash=null,string[] keys=null)=>File.WriteAllText(permit,JsonConvert.SerializeObject(new{Version=1,Enabled=true,PlayerId=id,Api=api,BackupPath=backup,BackupSha256=hash??digest,AllowedKeys=keys??new[]{"NewPlayerDataDic","k"}}));
Put();var value=Load(permit);Check(value!=null,"valid explicit permit");
Put(id:"102");Reject(permit,"backup account must match");
Put(id:"103");Reject(permit,"outside user-selected accounts");
Put(api:"http://elsewhere.invalid/");Reject(permit,"fixed formal API");
Put(hash:new string('0',64));Reject(permit,"backup integrity");
Put(keys:Array.Empty<string>());Reject(permit,"no empty permission");
Put(keys:new[]{"k"});Reject(permit,"core whole-save permission required");
Put(keys:new[]{"NewPlayerDataDic","new-unbacked-key"});Reject(permit,"no keys absent from backup without new reviewed fixture");
Put(keys:new[]{"NewPlayerDataDic","NewPlayerDataDic"});Reject(permit,"duplicate key permission rejected");
var originalBackup=File.ReadAllBytes(backup);
File.WriteAllText(backup,JsonConvert.SerializeObject(new{playerId="101",api="http://s101.local:18080/",data=new Dictionary<string,string>()}));
digest=Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(backup)));
Put(keys:new[]{"NewPlayerDataDic"});Reject(permit,"actual empty existing save cannot enable loop");
File.WriteAllBytes(backup,originalBackup);digest=Convert.ToHexString(SHA256.HashData(originalBackup));
Put();value=Load(permit);
var claim=type.GetMethod("Claim",BindingFlags.Static|BindingFlags.NonPublic);
string Claim(string instance)=>(string)claim.Invoke(null,new object[]{root,instance});
var marker=Claim("process-1");Check(File.Exists(marker),"durable claim");
bool denied=false;try{Claim("process-1");}catch(TargetInvocationException e){denied=e.InnerException is IOException;}Check(denied,"same process and reload rejected");
Check(File.Exists(Claim("process-2")),"new process can claim");
Check(File.Exists(marker),"old process mark retained");
Console.WriteLine($"PASS permit-and-process-claim assertions={assertions} root={root}");return 0;
}catch(Exception e){Console.WriteLine($"FAIL assertions={assertions} {e}");return 1;}

View File

@@ -0,0 +1,68 @@
from pathlib import Path
import re
root=Path(__file__).resolve().parents[3]
scripts=root/'Client/Assets/Scripts'
def patch(rel, changes):
p=scripts/rel
b=p.read_bytes()
for old,new in changes:
new=new.replace('\n','\r\n').encode()
if b.count(new)==1: continue
pattern=b'\r?\n'.join(re.escape(s.encode()) for s in old.split('\n'))
matches=list(re.finditer(pattern,b))
assert len(matches)==1,(rel,old[:100],len(matches))
m=matches[0]; b=b[:m.start()]+new+b[m.end():]
p.write_bytes(b)
def branch(body): return '#if UNITY_EDITOR && !SERVICE_HOST\n'+body+'\n#endif\n'
loop='game.PlayerDataStorage.PlayerKVGameplayLoop'
patch('SubBoostrap.cs', [(' rootCtxGo.AddComponent<PlayFabMgr>();',branch(' '+loop+'.BeforeBootstrap();')+' rootCtxGo.AddComponent<PlayFabMgr>();')])
patch('Core/PlayFabMgr.cs', [
(' _instance = this;\n',' _instance = this;\n'+branch(' if(PlayerKVGameplayLoop.Enabled) return;')),
(' void InitBuffer()\n',branch(' internal void StartKVGameplayLoopSync()\n {\n if(!PlayerKVGameplayLoop.Ready || tokenSource!=null) return;\n tokenSource=new CancellationTokenSource();\n UpdateUserDataValue(tokenSource.Token);\n }\n internal void StopKVGameplayLoopSync()\n { tokenSource?.Cancel(); tokenSource?.Dispose(); tokenSource=null; }')+' void InitBuffer()\n'),
(' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare);',branch(' if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("PF archive comparison is disabled in KV loop.");')+' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare);'),
(' void OnApplicationPause(bool is_pause)\n {\n',' void OnApplicationPause(bool is_pause)\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled)\n {\n if(is_pause) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.FlushObservedAsync(); }\n else StartKVGameplayLoopSync();\n return;\n }')),
(' void OnApplicationQuit()\n {\n',' void OnApplicationQuit()\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }')),
(' void OnDestroy()\n {\n',' void OnDestroy()\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }')),
(' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update);',branch(' if(PlayerKVGameplayLoop.Enabled) { PlayerKVGameplayLoop.Write(key,data,remoteStore); return; }')+' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update);'),
(' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);',branch(' if(PlayerKVGameplayLoop.Enabled) return PlayerKVGameplayLoop.Read(key);')+' PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);'),
(' private void SyncData()\n {\n',' private void SyncData()\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled) { _=PlayerKVGameplayLoop.FlushObservedAsync(); return; }')),
(' public async Task<bool> SyncDataImmidiate(string key, string data)\n {\n',' public async Task<bool> SyncDataImmidiate(string key, string data)\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("Immediate/consuming saves are outside the equipment loop.");'))
])
patch('Services/UserService.cs',[
(' if (evt.SessionVersion != customServerMgr.SessionVersion) return;\n',' if (evt.SessionVersion != customServerMgr.SessionVersion) return;\n'+branch(' if('+loop+'.Enabled) { '+loop+'.Fault("API unauthorized; restart required."); return; }')),
(' private async Task<ELoginStep> LoginCore(Action<int> progress, int generation)\n {\n',' private async Task<ELoginStep> LoginCore(Action<int> progress, int generation)\n {\n'+branch(' if('+loop+'.Enabled) return await LoginKVGameplayLoopAsync(progress,generation);')),
(' GetUserData = true,', '#if UNITY_EDITOR && !SERVICE_HOST\n GetUserData = !'+loop+'.Enabled,\n#else\n GetUserData = true,\n#endif'),
(' InitData(playfabUserData, UserId);',branch(' if(!'+loop+'.Enabled)')+' InitData(playfabUserData, UserId);'),
(' customServerMgr.UseAccountSession(session);\n',' customServerMgr.UseAccountSession(session);\n'+branch(' if('+loop+'.Enabled) return true; // Identity is published only after all KV modules load.')),
(' public virtual void Logout()\n {\n',' public virtual void Logout()\n {\n'+branch(' if('+loop+'.Enabled) { _=StopKVGameplayLoopAsync(); return; }'))
])
patch('Services/AggUserService.cs',[
(' public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)\n {\n',' public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)\n {\n'+branch(' if('+loop+'.Enabled)\n {\n CustomId='+loop+'.Permit.PlayerId;\n OnInitData+=InitData;\n try { return await LoginWithBackend(progress,"kv-loop"); }\n finally { OnInitData-=InitData; }\n }'))
])
# KVLoad has CRLF source, and these edits leave the controlled SERVICE_HOST admission unchanged.
patch('Services/UserService.KVLoad.cs',[
(' internal bool RejectSaveBackend(string backend)\n {\n',' internal bool RejectSaveBackend(string backend)\n {\n'+branch(' if(PlayerKVGameplayLoop.Enabled && backend=="kv-loop" && !kvControlledInstance)\n { KVLastError=PlayerKVGameLoadError.None; return false; }')),
(' if(!kvControlledInstance || InternalId!=0 || apiSession!=null)',' bool admitted=kvControlledInstance, sessionAllowed=apiSession==null;\n'+branch(' admitted=admitted || KVLoopLoading;\n sessionAllowed=sessionAllowed || KVLoopLoading;')+' if(!admitted || InternalId!=0 || !sessionAllowed)'),
(' if(!kvControlledInstance || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");',' bool admitted=kvControlledInstance;\n'+branch(' admitted=admitted || KVLoopLoading;')+' if(!admitted || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");')
])
patch('Stages/LoginStage.cs',[
(' if (!LoadingScreen.isShowing())',branch(' if('+loop+'.Enabled) startActId="PFLoginAct";')+' if (!LoadingScreen.isShowing())')
])
patch('Stages/PFLoginAct.cs',[
(' var configCustomId = config.Get<string>("CustomID", string.Empty);',' var configCustomId = config.Get<string>("CustomID", string.Empty);\n'+branch(' if('+loop+'.Enabled) configCustomId='+loop+'.Permit.PlayerId;').rstrip('\n')),
(' if (step != ELoginStep.Done)\n {\n',' if (step != ELoginStep.Done)\n {\n'+branch(' if('+loop+'.Enabled) throw '+loop+'.Stop("Login failed; restart the Unity process before another attempt.");'))
])
patch('Stages/QuitAct.cs',[
(' public override async Task<bool> StartAsync()\n {\n',' public override async Task<bool> StartAsync()\n {\n'+branch(' if('+loop+'.Enabled) _='+loop+'.CloseAsync();')),
(' if(userService is PlayFabUserService kvUser) await kvUser.ObserveKVQuitAsync();',branch(' if('+loop+'.Enabled) await PlayerDataStorage.PlayerKVShutdownRegistry.Process.ObserveAsync(5000);\n else')+' if(userService is PlayFabUserService kvUser) await kvUser.ObserveKVQuitAsync();')
])
patch('DataCenter/DataCenter.PlayerData.cs',[
(' public void SavePlayerData(bool remote = true)\n {\n GContext.Publish<PlayerData>(this);',' public void SavePlayerData(bool remote = true)\n {\n GContext.Publish<PlayerData>(this);\n SerializeAndSavePlayerData(remote);\n }\n\n'+branch(' internal async System.Threading.Tasks.Task SetEquipRodIDForKVLoopAsync(int id)\n {\n var batch='+loop+'.BeginEquip();\n game.PlayerDataStorage.PlayerKVEditResult primary;\n try\n {\n using(batch)\n {\n equipRodID=id;\n GContext.container.Resolve<PlayerFishData>().SetRedPoint();\n GContext.Publish<PlayerData>(this);\n primary=SerializeAndSavePlayerData(true);\n }\n await '+loop+'.ConfirmEquipAsync(batch,primary);\n }\n catch(System.Exception e) { '+loop+'.Fault(e.Message); throw; }\n }')+' private game.PlayerDataStorage.PlayerKVEditResult SerializeAndSavePlayerData(bool remote)\n {'),
(' PlayFabMgr.Instance.UpdateUserDataValue("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote);',branch(' if('+loop+'.Enabled) return '+loop+'.Write("NewPlayerDataDic",Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic),remote);')+' PlayFabMgr.Instance.UpdateUserDataValue("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote);\n return null;')
])
patch('UI/Rod/FishingRodPanel_Level.cs',[
(' void OnClickEquip()\n', '#if UNITY_EDITOR && !SERVICE_HOST\n bool kvEquipInFlight;\n async void OnClickEquip()\n#else\n void OnClickEquip()\n#endif\n'),
(' GContext.container.Resolve<PlayerData>().SetEquipRodID(data.config.ID);',branch(' if('+loop+'.Enabled)\n {\n if(kvEquipInFlight) return;\n kvEquipInFlight=true;\n try { await GContext.container.Resolve<PlayerData>().SetEquipRodIDForKVLoopAsync(data.config.ID); }\n catch(System.Exception e) { Debug.LogException(e); return; }\n finally { kvEquipInFlight=false; }\n }\n else')+' GContext.container.Resolve<PlayerData>().SetEquipRodID(data.config.ID);')
])
print('R1 whitelist patches applied without normalizing source bytes.')

View File

@@ -0,0 +1,27 @@
from pathlib import Path
import json,hashlib,difflib
root=Path(__file__).resolve().parents[3]
folder=Path(__file__).resolve().parent
baseline=json.loads((folder/'checkpoint0-baseline.json').read_text(encoding='utf-8-sig'))
changes=[]
for item in baseline['files']:
p=root/item['path']
digest=hashlib.sha256(p.read_bytes()).hexdigest().upper()
if digest!=item['sha256']:
before=folder/'before'/(item['path'].replace('/','__').replace('\\','__')+'.txt')
if item['path'].startswith('Client/'):
assert before.exists(),before
old=before.read_text(encoding='utf-8-sig').splitlines(); new=p.read_text(encoding='utf-8-sig').splitlines()
diff=list(difflib.unified_diff(old,new,fromfile=item['path']+' (reviewed baseline)',tofile=item['path']))
(folder/('diff-'+p.name+'.txt')).write_text('\n'.join(diff)+'\n',encoding='utf-8')
changes.append(dict(path=item['path'],before=item['sha256'],after=digest,added=sum(s.startswith('+') and not s.startswith('+++') for s in diff),removed=sum(s.startswith('-') and not s.startswith('---') for s in diff)))
pf='Client/Assets/Scripts/Core/PlayFabMgr.cs'
before=(folder/'before'/(pf.replace('/','__')+'.txt')).read_bytes()
after=(root/pf).read_bytes()
key=b' internal static PlayerKVKeyReadResult ReadKV('
assert before[before.index(key):]==after[after.index(key):],'B3A four fixed entries changed'
newpaths=['Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs','Client/Assets/Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs','Client/Assets/Editor/KVGameplayLoopSetup.cs']
newfiles=[dict(path=p,sha256=hashlib.sha256((root/p).read_bytes()).hexdigest().upper()) for f in newpaths for p in (f,f+'.meta')]
out=dict(changedExistingSources=changes,newFiles=newfiles,B3AFixedEntriesByteIdentical=True,gameplayEnabled=False)
(folder/'checkpoint2-source-audit.json').write_text(json.dumps(out,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(out,ensure_ascii=False,indent=2))

View File

@@ -0,0 +1,48 @@
from pathlib import Path
import re,uuid
root=Path(__file__).resolve().parents[3]
src=root/'Client/Assets/Scripts/Services/PlayerData/PlayerKVEditorValidationHost.cs'
text=src.read_text(encoding='utf-8')
snapshot=text[text.index(' var snapshot=new PlayerKVSchemaSnapshot("local-full-4B2"'):]
snapshot=snapshot[:snapshot.index('\n ValidationContextAccess.Use')]
snapshot=snapshot.replace('var snapshot=', 'return ').replace('"local-full-4B2"','"kv-loop"').replace('new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc)','nowUtc')
bindings=text[text.index(' var player=new GameCore.PlayerData(tables,null);'):]
bindings=bindings[:bindings.index('\n user.ConfigureKVForValidation')]
start=bindings.index(' var modules=')
declarations=bindings[:start]
declarations=re.sub(r'new ([\w.]+)\([^;]*?\)',lambda m:'Require<'+m[1]+'>()',declarations)
declarations=declarations.replace('var foot=Require<FootPrintService>()','var foot=Require<IFootPrintService>() as FootPrintService ?? throw new InvalidOperationException("M23 FootPrint binding mismatch.")')
declarations=declarations[:declarations.index(' var pinataTarget=')]+' var pinata=Require<EventPinataPackDataManager>(); var digging=Require<Script.RuntimeScript.DiggingGameManager>();\n'
body=bindings[start:].replace('transaction=new PlayerKVGameLoadTransaction','return new PlayerKVGameLoadTransaction')
code='''#if UNITY_EDITOR && !SERVICE_HOST
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using asap.core;
namespace game.PlayerDataStorage
{
// Bind the existing game container. Missing registrations fail closed; no parallel fixture objects.
internal static class PlayerKVGameplayBindings
{
static T Require<T>() where T:class => GContext.container.Resolve<T>() ??
throw new InvalidOperationException("KV game module is not registered: "+typeof(T).FullName);
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc)
{
var tables=Require<cfg.Tables>();
'''+snapshot+'''
}
internal static PlayerKVGameLoadTransaction Bind(PlayFabUserService user)
{
'''+declarations+body+'''
}
}
}
#endif
'''
(src.parent/'PlayerKVGameplayBindings.cs').write_text(code,encoding='utf-8')
for rel in ['Scripts/Services/UserService.KVGameplayLoop.cs','Scripts/Services/PlayerData/PlayerKVGameplayBindings.cs','Editor/KVGameplayLoopSetup.cs']:
p=root/'Client/Assets'/ (rel+'.meta')
if not p.exists(): p.write_text('fileFormatVersion: 2\nguid: '+uuid.uuid4().hex+'\n',encoding='utf-8')
print('Created fixed bindings and new GUIDs; existing assets unchanged.')

View File

@@ -0,0 +1,33 @@
--- Client/Assets/Scripts/Services/AggUserService.cs (reviewed baseline)
+++ Client/Assets/Scripts/Services/AggUserService.cs
@@ -56,6 +56,15 @@
public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
+ {
+ CustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
+ OnInitData+=InitData;
+ try { return await LoginWithBackend(progress,"kv-loop"); }
+ finally { OnInitData-=InitData; }
+ }
+#endif
var backend=CaptureSaveBackend();
if(RejectSaveBackend(backend)) return ELoginStep.Api;
var generation = logoutGeneration;
@@ -224,6 +233,10 @@
}
private void InitData(IDictionary<string, string> data)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ // Editor loop has no channel SDK login. M31 already replaced account URLs and linked flags.
+ if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
+#endif
if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle);

View File

@@ -0,0 +1,49 @@
--- Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs (reviewed baseline)
+++ Client/Assets/Scripts/DataCenter/DataCenter.PlayerData.cs
@@ -1139,6 +1139,30 @@
public void SavePlayerData(bool remote = true)
{
GContext.Publish<PlayerData>(this);
+ SerializeAndSavePlayerData(remote);
+ }
+
+#if UNITY_EDITOR && !SERVICE_HOST
+ internal async System.Threading.Tasks.Task SetEquipRodIDForKVLoopAsync(int id)
+ {
+ var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginEquip();
+ game.PlayerDataStorage.PlayerKVEditResult primary;
+ try
+ {
+ using(batch)
+ {
+ equipRodID=id;
+ GContext.container.Resolve<PlayerFishData>().SetRedPoint();
+ GContext.Publish<PlayerData>(this);
+ primary=SerializeAndSavePlayerData(true);
+ }
+ await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmEquipAsync(batch,primary);
+ }
+ catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; }
+ }
+#endif
+ private game.PlayerDataStorage.PlayerKVEditResult SerializeAndSavePlayerData(bool remote)
+ {
StringBuilder sb = new StringBuilder();
sb.Append(lv).Append(",");
sb.Append(gold).Append(",");
@@ -1179,7 +1203,11 @@
NewPlayerDataDic["ct"] = GContext.container.Resolve<FishingEventData>().CTSerialize();
NewPlayerDataDic["rt"] = GContext.container.Resolve<FishingEventData>().RTSerialize(); ;
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return game.PlayerDataStorage.PlayerKVGameplayLoop.Write("NewPlayerDataDic",Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic),remote);
+#endif
PlayFabMgr.Instance.UpdateUserDataValue("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote);
+ return null;
}
public void Init()
{

View File

@@ -0,0 +1,37 @@
--- Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs (reviewed baseline)
+++ Client/Assets/Scripts/UI/Rod/FishingRodPanel_Level.cs
@@ -228,7 +228,12 @@
fx_rodpanel_upgrade_change.SetActive(false);
}
+#if UNITY_EDITOR && !SERVICE_HOST
+ bool kvEquipInFlight;
+ async void OnClickEquip()
+#else
void OnClickEquip()
+#endif
{
if (data.config.ID == GContext.container.Resolve<PlayerData>().equipRodID || data.isLock)
{
@@ -239,6 +244,17 @@
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_26"));
return;
}
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
+ {
+ if(kvEquipInFlight) return;
+ kvEquipInFlight=true;
+ try { await GContext.container.Resolve<PlayerData>().SetEquipRodIDForKVLoopAsync(data.config.ID); }
+ catch(System.Exception e) { Debug.LogException(e); return; }
+ finally { kvEquipInFlight=false; }
+ }
+ else
+#endif
GContext.container.Resolve<PlayerData>().SetEquipRodID(data.config.ID);
btn_equip_go.gameObject.SetActive(false);
equipped.SetActive(true);

View File

@@ -0,0 +1,15 @@
--- Client/Assets/Scripts/Stages/LoginStage.cs (reviewed baseline)
+++ Client/Assets/Scripts/Stages/LoginStage.cs
@@ -89,6 +89,9 @@
#else
startActId = "PFLoginAct";
#endif
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) startActId="PFLoginAct";
+#endif
if (!LoadingScreen.isShowing())
{
await LoadingScreen.Show();

View File

@@ -0,0 +1,26 @@
--- Client/Assets/Scripts/Stages/PFLoginAct.cs (reviewed baseline)
+++ Client/Assets/Scripts/Stages/PFLoginAct.cs
@@ -71,6 +71,9 @@
loginToken.ThrowIfCancellationRequested();
UpdateLoadingInfo();
var configCustomId = config.Get<string>("CustomID", string.Empty);
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) configCustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
+#endif
if (configCustomId == @"{{input}}")
{
LoadingScreen.HideProgressBar();
@@ -93,6 +96,9 @@
loginToken.ThrowIfCancellationRequested();
if (step != ELoginStep.Done)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Login failed; restart the Unity process before another attempt.");
+#endif
//source.Cancel();
//source.Dispose();
await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed")

View File

@@ -0,0 +1,123 @@
--- Client/Assets/Scripts/Core/PlayFabMgr.cs (reviewed baseline)
+++ Client/Assets/Scripts/Core/PlayFabMgr.cs
@@ -45,6 +45,9 @@
public Dictionary<string, string> CompareDatas(Dictionary<string, UserDataRecord> remoteDatas, string userId)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("PF archive comparison is disabled in KV loop.");
+#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare);
Last_Update_Time_Key = $"{Last_Update_Time_Key_Prefix}_{userId}";
Local_Data_Key = $"{Local_Data_Key_prefix}_{userId}";
@@ -121,11 +124,24 @@
{
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
_instance = this;
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) return;
+#endif
InitBuffer();
tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
+#if UNITY_EDITOR && !SERVICE_HOST
+ internal void StartKVGameplayLoopSync()
+ {
+ if(!PlayerKVGameplayLoop.Ready || tokenSource!=null) return;
+ tokenSource=new CancellationTokenSource();
+ UpdateUserDataValue(tokenSource.Token);
+ }
+ internal void StopKVGameplayLoopSync()
+ { tokenSource?.Cancel(); tokenSource?.Dispose(); tokenSource=null; }
+#endif
void InitBuffer()
{
dataBuffer = new LinkedList<Dictionary<string, string>>();
@@ -138,6 +154,14 @@
void OnApplicationPause(bool is_pause)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled)
+ {
+ if(is_pause) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.FlushObservedAsync(); }
+ else StartKVGameplayLoopSync();
+ return;
+ }
+#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
if (is_pause)
{
@@ -154,6 +178,9 @@
void OnApplicationQuit()
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
+#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
tokenSource.Cancel();
tokenSource = null;
@@ -162,6 +189,9 @@
void OnDestroy()
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
+#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
tokenSource?.Cancel();
tokenSource = null;
@@ -185,6 +215,9 @@
public void UpdateUserDataValue(string key, string data, bool remoteStore = true)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) { PlayerKVGameplayLoop.Write(key,data,remoteStore); return; }
+#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update);
if (remoteStore)
{
@@ -215,6 +248,9 @@
/// <returns></returns>
public string GetLocalData(string key)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) return PlayerKVGameplayLoop.Read(key);
+#endif
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);
if (localData.ContainsKey(key))
{
@@ -228,6 +264,9 @@
private void SyncData()
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) { _=PlayerKVGameplayLoop.FlushObservedAsync(); return; }
+#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) return;
// Logout clears credentials before RootCtx is destroyed. Keep pending data local.
if (!PlayFabClientAPI.IsClientLoggedIn()) return;
@@ -283,6 +322,9 @@
public async Task<bool> SyncDataImmidiate(string key, string data)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("Immediate/consuming saves are outside the equipment loop.");
+#endif
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false;
foreach (var buff in dataBuffer)
{

View File

@@ -0,0 +1,27 @@
--- Client/Assets/Scripts/Stages/QuitAct.cs (reviewed baseline)
+++ Client/Assets/Scripts/Stages/QuitAct.cs
@@ -34,6 +34,9 @@
public override async Task<bool> StartAsync()
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) _=game.PlayerDataStorage.PlayerKVGameplayLoop.CloseAsync();
+#endif
if(userService is PlayFabUserService closingKVUser) _=closingKVUser.CloseKVForValidationAsync();
// Invalidate identity before any await can allow old login/HTTP callbacks to resume.
if (QuitStage.Logout)
@@ -51,6 +54,10 @@
catch (System.Exception ex) { Debug.LogException(ex); }
// Observation is bounded; the process registry still owns any unfinished close.
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) await PlayerDataStorage.PlayerKVShutdownRegistry.Process.ObserveAsync(5000);
+ else
+#endif
if(userService is PlayFabUserService kvUser) await kvUser.ObserveKVQuitAsync();
adsService.Release();

View File

@@ -0,0 +1,15 @@
--- Client/Assets/Scripts/SubBoostrap.cs (reviewed baseline)
+++ Client/Assets/Scripts/SubBoostrap.cs
@@ -84,6 +84,9 @@
GameCore.GameEventMgr.Init(true);
#else
GameCore.GameEventMgr.Init();
+#endif
+#if UNITY_EDITOR && !SERVICE_HOST
+ game.PlayerDataStorage.PlayerKVGameplayLoop.BeforeBootstrap();
#endif
rootCtxGo.AddComponent<PlayFabMgr>();
// TODO Replace DataCenter with seprated data class

View File

@@ -0,0 +1,43 @@
--- Client/Assets/Scripts/Services/UserService.KVLoad.cs (reviewed baseline)
+++ Client/Assets/Scripts/Services/UserService.KVLoad.cs
@@ -16,6 +16,10 @@
internal string CaptureSaveBackend() => config==null ? "playfab" : config.Get<string>("SaveBackend","playfab");
internal bool RejectSaveBackend(string backend)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(PlayerKVGameplayLoop.Enabled && backend=="kv-loop" && !kvControlledInstance)
+ { KVLastError=PlayerKVGameLoadError.None; return false; }
+#endif
KVLastError=backend=="kv" ? PlayerKVGameLoadError.BackendNotEnabled : backend!="playfab" ? PlayerKVGameLoadError.BackendUnsupported :
kvControlledInstance ? PlayerKVGameLoadError.Input : PlayerKVGameLoadError.None;
return KVLastError!=PlayerKVGameLoadError.None;
@@ -75,14 +79,23 @@
internal virtual void ResetForKVLoad()
{
- if(!kvControlledInstance || InternalId!=0 || apiSession!=null)
+ bool admitted=kvControlledInstance, sessionAllowed=apiSession==null;
+#if UNITY_EDITOR && !SERVICE_HOST
+ admitted=admitted || KVLoopLoading;
+ sessionAllowed=sessionAllowed || KVLoopLoading;
+#endif
+ if(!admitted || InternalId!=0 || !sessionAllowed)
throw new InvalidOperationException("Unstarted controlled identity required.");
KVIntents?.Discard(); KVIntents=null; KVLoadingCopy=null;
MMTestValue=0; EAccoutUrl=new Dictionary<string,string>(StringComparer.Ordinal); KVTapJoyBalance=0;
}
internal void ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
{
- if(!kvControlledInstance || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");
+ bool admitted=kvControlledInstance;
+#if UNITY_EDITOR && !SERVICE_HOST
+ admitted=admitted || KVLoopLoading;
+#endif
+ if(!admitted || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");
var copy=raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
var intents=new PlayerKVInitializationIntents(epoch,state.Channel);
foreach(var pair in state.Normalizations) { copy[pair.Key]=pair.Value; intents.Add(epoch,pair.Key,pair.Value); }

View File

@@ -0,0 +1,72 @@
--- Client/Assets/Scripts/Services/UserService.cs (reviewed baseline)
+++ Client/Assets/Scripts/Services/UserService.cs
@@ -155,6 +155,9 @@
GContext.OnEvent<EvtAPISvrUnauthorized>().Subscribe(evt =>
{
if (evt.SessionVersion != customServerMgr.SessionVersion) return;
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault("API unauthorized; restart required."); return; }
+#endif
InternalId = 0;
if (PlayFabClientAPI.IsClientLoggedIn()) _ = LoginApiServer();
});
@@ -185,6 +188,9 @@
private async Task<ELoginStep> LoginCore(Action<int> progress, int generation)
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return await LoginKVGameplayLoopAsync(progress,generation);
+#endif
if (IsLogin)
{
progress?.Invoke(2);
@@ -344,7 +350,11 @@
var info_request_parameters = new GetPlayerCombinedInfoRequestParams
{
GetUserAccountInfo = true,
+#if UNITY_EDITOR && !SERVICE_HOST
+ GetUserData = !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled,
+#else
GetUserData = true,
+#endif
GetPlayerProfile = true,
ProfileConstraints = new PlayerProfileViewConstraints()
{
@@ -507,6 +517,9 @@
//InitData should invoke after local time adjusted.
//save login time to local storage.
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
+#endif
InitData(playfabUserData, UserId);
return true;
@@ -546,6 +559,9 @@
return false;
}
customServerMgr.UseAccountSession(session);
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true; // Identity is published only after all KV modules load.
+#endif
InternalId = session.InternalId;
GContext.Publish(new EvtAPISvrLoginSuccess
{
@@ -655,6 +671,9 @@
public virtual void Logout()
{
+#if UNITY_EDITOR && !SERVICE_HOST
+ if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=StopKVGameplayLoopAsync(); return; }
+#endif
if(kvControlledInstance)
{
logoutGeneration++; InternalId=0; UserId=null; DisplayName=null; AvatarUrl=null;

View File

@@ -0,0 +1,61 @@
from pathlib import Path
import json,hashlib
folder=Path(__file__).resolve().parents[1]; root=folder.parents[1]; evidence=folder/'KVGameplayLoopTests'
entry='''## 当前有效状态:R1最小接线已编写与初验,真实闭环未完成(2026-09-24)
R1原方案已完整审查并逐条裁决,见[实施批准](阶段4最小真实玩法闭环方案审查裁决与实施批准-2026-09-24.md)。本轮已编写白名单接线:10个旧源码增加152行/删除2行,新增3源码及meta;不是全量业务重构。B3A四入口保持原始字节。
**实际阻塞:101/102现有KV均为空,且M24对象缺登录期注册。** 用户已允许沿用两账号;已只读备份,没有KV写入、自动PF迁移或造档。M24最小注册补丁尚未执行,具体补丁和独立审查提示词见[实施进度与最小补充审查](阶段4最小真实玩法闭环-实施进度与最小补充审查-2026-09-24.md)。
- [x] R1-Review/C0:70项hash核对、九合同及2P2/3P3裁决、源码快照、账号授权、现有KV备份。
- [x] R1-C1/C2已做部分:白名单接线、许可15断言、默认服务双条件各8组73断言、4A/4B1/4B2/4B3A离线回归及Unity既有固定用例通过。
- [ ] R1-C1/C2剩余:M24最小注册差异独立审查→主会话裁决/实施;新装备链专用验证仍缺(动态脚本被CLI Restricted拦截,未执行,不绕过)。
- [ ] R1-数据来源:101/102 KV均0键;等待完整KV备份来源,或用户选择先只读检查同账号PF档,再列实际导入key/摘要/恢复范围。账号许可无需重复询问。
- [ ] R1-C3:真实UI装备、全部原票据Confirmed、Unity重启/新缓存恢复、独立实施复核及关闭,均未完成。
最终Unity编译0错误、270警告(完整原输出保留,新R1文件无警告);未启用运行许可、未PlayMode、不打包。成功正式请求仅2登录+2Get,Set/Delete=0。责任:评审侧审M24具体差异;数据来源等待用户选择;主会话负责裁决、实施和后续验证。4B1/4B2/4B3A已关闭;整体4B3/G4、4C、热换号及P2-α真实迁移仍未完成。正式地址http://s101.local:18080/,Unity CLI Loop,不用UnitySkills。
### 以下为历史快照(当前以本节为准)
'''
for name in ['接手入口.md','开发状态.md','TODO.md']:
p=folder/name;old=p.read_text(encoding='utf-8');head,rest=old.split('\n',1)
# Replace the current in-turn snapshot; leave earlier historical records intact.
delimiter='### 以下为历史快照(当前以本节为准)'
rest=rest[rest.index(delimiter)+len(delimiter):].lstrip()
p.write_text(head+'\n\n'+entry+rest,encoding='utf-8')
record='''
## 检查点1/2收尾:已验证部分与未完成项
更正本轮中间记录的文件计数:实际修改10个旧源码(152行新增/2行删除),新增3源码+3meta;审计只与R1已审基线比较。B3A四固定方法所在尾段逐字节不变。`checkpoint2-source-audit.json`及逐文件diff保存。没有修改底层/31业务算法/场景/包/服务器。
许可测试补齐空档/重复key后15断言通过。默认服务非Android/Android+AGG各8组73断言;4A 18组226、4B1 23组279、4B2 75组1715、4B3A 13组544均通过。File.Replace沙箱拒绝有诊断堆栈,原首败及获准同程序通过均保留,不修改实现去迎合沙箱,也不关闭历史间歇问题。Unity原固定入口B3A 34、B2-20 83断言通过且恢复成功。
最终CLI编译0错误270警告,新R1文件无警告;既有变更文件上的4条告警位于未修改的方法,其余在未改文件,本轮不清理。早先增量0警告不等于整个工程零告警。源码中补充实际API URI必须等于许可固定s101地址;Editor Agg通知复用已解码M31,不读取未登录的渠道SDK AccountInfo。
新R1装备链动态合成脚本第一次因Json双程序集而编译失败,修正脚本后CLI Restricted在执行前拒绝反射/HttpMessageHandler等API。未执行,不算测试通过;未降低安全级别或另设旁路。既有固定回归入口独立运行,不能代替这个新链覆盖。正式PlayMode仍未执行。
《阶段4最小真实玩法闭环-实施进度与最小补充审查-2026-09-24.md》已给出M24准确注册补丁和评审提示词,待差异审查后落代码。101/102实际KV均空档;初始来源选择未回复,不自动迁移PF或创建数据。当前只完成白名单接线和初验,不标记C1/C2全部完成,也不关闭R1。下一步:外部审M24具体差异、用户确定初始存档来源;主会话随后逐条裁决并继续执行。
'''
for name in ['阶段4最小真实玩法闭环实施记录-2026-09-24.md','开发日志.md']:
with (folder/name).open('a',encoding='utf-8') as f:f.write(record)
p=folder/'阶段4B3当前TodoItem-2026-09-24.json';d=json.loads(p.read_text(encoding='utf-8'))
d.update(phase='R1-wiring-initial-checks-M24-supplement-and-save-source-pending',nextResponsibleSide='reviewer-M24-and-user-save-source',implementationScope='R1 original whitelist approved; M24 early registration supplement pending; real gameplay not verified',R1Closed=False)
d['minimalGameplayConstraints']['newImplementationApproved']=True
for item in d['todoItem']:
if item['id'] in ['Minimal-Gameplay-Design','R1-Review']:
item.update(status='completed',detail='R1独立方案审查接受;70项hash、九合同和2P2/3P3已逐条裁决,原白名单编码批准')
if item['id']=='R1-C1-C2': item.update(status='partially-complete',detail='接线与必要旧回归已完成;M24注册补审、新链专用测试及真实加载仍缺')
d['todoItem'] += [dict(id='R1-M24-supplement',status='awaiting-independent-review',detail='仅LoginStage中Editor模式提前注册已有M24单例;精确补丁及提示词已落盘'),dict(id='R1-data-source',status='awaiting-source',detail='已备份101/102空KV,无业务写入;等待初始完整档来源,不自动PF迁移')]
p.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
baseline=json.loads((evidence/'checkpoint0-baseline.json').read_text(encoding='utf-8'))
paths={f['path'] for f in baseline['files'] if f['path'].startswith('Client/')}
audit=json.loads((evidence/'checkpoint2-source-audit.json').read_text(encoding='utf-8'))
paths.update(f['path'] for f in audit['newFiles'])
for name in ['阶段4最小真实玩法闭环方案-待评审-2026-09-24.md','阶段4最小真实玩法闭环方案独立审查-2026-09-24.md','阶段4最小真实玩法闭环方案审查裁决与实施批准-2026-09-24.md','阶段4最小真实玩法闭环-实施进度与最小补充审查-2026-09-24.md']:
paths.add('CodexOutput/upserver/'+name)
for name in ['checkpoint2-source-audit.json','Program.cs','KVGameplayLoopTests.csproj','Preflight/Program.cs','Preflight/Preflight.csproj','unity-loop-synthetic.cs.txt','checkpoint2-unity-loop-synthetic-retry.json','checkpoint2-unity-compile-final.log','checkpoint2-unity-existing-regressions.json']:
paths.add('CodexOutput/upserver/KVGameplayLoopTests/'+name)
files=[dict(path=s,sha256=hashlib.sha256((root/s).read_bytes()).hexdigest().upper()) for s in sorted(paths)]
(evidence/'checkpoint2-review-input-hashes.json').write_text(json.dumps(dict(scope='M24 minimal supplement; R1 implementation not complete',files=files),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(f'Updated status and logs; sealed {len(files)} review inputs.')

View File

@@ -0,0 +1,78 @@
"""Record completed R1 repair checkpoint; no game/server mutations."""
import hashlib
import json
from pathlib import Path
root = Path(__file__).resolve().parents[3]
docs = root / "CodexOutput/upserver"
evidence = docs / "KVGameplayLoopTests"
title = "## 当前有效状态:R1正式关闭,两处运行报错修复并验证(2026-09-24)"
status = """[独立实施复核](阶段4最小真实玩法闭环R1实施独立复核-2026-09-24.md)的210项基线一致,五项回答及P2-1/P3-1/P3-2已逐条裁决;见[修复验收与关闭裁决](阶段4R1报错修复验收与关闭裁决-2026-09-24.md)。
- [x] 建筑缺键默认恢复为字符串0;原getter正常读取,不新增服务器键。
- [x] Editor KV测试模式正常退出不再自动重进;排水Ready后自动停止Play,保留claimed。普通退出行为保持。
- [x] 4B2全量75组、B3A全量13组、真实QuitAct条件测试、Unity M01/B2-20/装备链及许可均通过;编译0错误。
- [x] 新Unity101820真实登录102,建筑数据/等级0、装备30003与主要货币一致,正常退出自动结束Play,无原两类错误。
- [x] 前后只读对账:101仍空、102原45键逐值完全相同;许可归档,三个claimed保留,Unity已正常退出。
- [x] R1最小单账号真实存档闭环关闭;不必再重复上一轮R1实施复核。
- [ ] 整体4B3/G4、完整B/C热换号、4C、P2-α存量档专项、身份/外围及最终包仍未完成;下一步先依当前“最小改动、先跑起来”目标收敛后续范围,完整方案评审后编码。
本轮生产修复只有Schema一个默认值及QuitAct一个Editor模式分支。建筑修订有独立批准,退出补充由主会话编码前完整评审,二者审批来源有区别。签到关闭回调测试辅助边界保留。退出CLI await超时和一次不支持命令的工具错误已如实记录;闭环成功依赖独立事件/状态/对账证据,不把工具异常抹去。
固定http://s101.local:18080/;Unity CLI Loop、不用UnitySkills、未打包。无活动permit,102禁止重复导入,下次运行必须新进程。
"""
for name in ("接手入口.md", "开发状态.md", "TODO.md"):
path = docs / name
text = path.read_text(encoding="utf-8-sig")
if title not in text:
header, rest = text.split("\n", 1)
path.write_text(header + "\n\n" + title + "\n\n" + status + "\n### 以下为历史快照(当前以本节为准)\n" + rest, encoding="utf-8")
checkpoint = """
### 2026-09-24 R1报错修复检查点3及关闭
- 新进程101820_639258292892095408真实102登录成功,缺键建筑及等级均0;装备/等级/主要货币保持。登录与正常退出阶段原两类异常均未出现。
- 正常QuitStage于06:51:45Z closed,Registry Ready后自动停止Play;没有手动Stop。06:53:55只读Hierarchy确认editor状态。三个claimed保留、permit归档,最后CLI正常退出Unity。
- 前后backups/20260924T064749Z至20260924T065309Z:101空,102原45字符串全相同,不新增建筑key。本轮自然同值写1次,不能当作HTTP计数。
- 工具限制:退出脚本跨Play停止的await未返回并180秒超时;误用Editor未注册的get-project-info产生单独工具错误。完整证据保存、未清Console。关闭成功由事件、Ready日志、非运行Hierarchy及对账证明。
- 独立复核P2-1/P3-1/P3-2处理完成,R1正式关闭;签到关闭回调辅助边界仍披露,完整4B3/G4等未完成。详见《阶段4R1报错修复验收与关闭裁决-2026-09-24.md》。
"""
for name in ("开发日志.md", "阶段4最小真实玩法闭环实施记录-2026-09-24.md", "阶段4R1报错修复裁决与最小实施方案-2026-09-24.md"):
path = docs / name
text = path.read_text(encoding="utf-8-sig")
if "### 2026-09-24 R1报错修复检查点3及关闭" not in text:
path.write_text(text.rstrip() + "\n" + checkpoint, encoding="utf-8")
path = docs / "阶段4B3当前TodoItem-2026-09-24.json"
data = json.loads(path.read_text(encoding="utf-8-sig"))
data.update(phase="R1-closed-two-runtime-defects-fixed-and-verified", R1Closed=True,
nextResponsibleSide="main-session-next-scope-design-before-code", newProcessId=101820,
implementationScope="R1 closed; two minimal runtime repairs verified, overall 4B3/G4 incomplete",
runtimeLocalWriteAttempts=7,
formalRequestDetail="原工具成功请求累计16;本次前后只读备份各4,共新增8。运行时HTTP次数未知,本轮本地同值写尝试1。")
updates = {
"R1-C1-C2": "最小接线与两处修复完成,相关全量/Unity回归通过;受审基线核对及逐项裁决已落盘",
"R1-C3": "真实UI装备/票据/服务器对账/新进程恢复通过;补充真实登录无建筑NRE、正常退出自动停止Play;辅助边界保留",
"R1-building-default": "独立批准的一默认值修复,75组/真实M01/102登录通过,服务器不新增key",
}
for item in data["todoItem"]:
if item["id"] in updates:
item.update(status="closed", detail=updates[item["id"]])
for item in [
{"id":"R1-exit-reentry", "status":"closed", "detail":"主会话编码前补充评审;Editor测试模式排水Ready才Stop且不自动回PreBoot,实际QuitAct回归及真实退出通过"},
{"id":"R1-implementation-review-close", "status":"closed", "detail":"210基线一致,5专项及P2-1/P3-1/P3-2逐项裁决;补丁与全量/真实回归完成,限定R1关闭"},
]:
if not any(x["id"] == item["id"] for x in data["todoItem"]):
data["todoItem"].append(item)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
old = json.loads((evidence / "real102-implementation-review-hashes.json").read_text(encoding="utf-8-sig"))
changed = [x["path"] for x in old["files"] if hashlib.sha256((root/x["path"]).read_bytes()).hexdigest().upper() != x["sha256"].upper()]
(evidence / "repair-baseline-delta.json").write_text(json.dumps({"baselineEntries":len(old["files"]),"changedExistingPaths":changed}, ensure_ascii=False, indent=2), encoding="utf-8")
paths = {root / p for p in changed}
paths.update(p for p in evidence.glob("repair-*") if p.is_file() and p.name != "repair-final-hashes.json")
paths.update(evidence.glob("unity-repair-*.cs.txt"))
paths.update(evidence / "ExitRegression" / p for p in ("ExitRegression.csproj","Program.cs.txt"))
paths.update(docs / p for p in ("阶段4R1报错修复裁决与最小实施方案-2026-09-24.md","阶段4R1报错修复验收与关闭裁决-2026-09-24.md","接手入口.md","开发状态.md","TODO.md","开发日志.md","阶段4B3当前TodoItem-2026-09-24.json"))
paths.add(Path(__file__).resolve())
(evidence / "repair-final-hashes.json").write_text(json.dumps({"scope":"R1 two-error repair and closure evidence; original review manifest preserved", "files":[{"path":p.relative_to(root).as_posix(),"sha256":hashlib.sha256(p.read_bytes()).hexdigest().upper()} for p in sorted(paths)]}, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"updated":True,"oldBaselineChanged":changed,"finalHashEntries":len(paths)}, ensure_ascii=False))

View File

@@ -0,0 +1,69 @@
from pathlib import Path
import json,hashlib
base=Path(__file__).resolve().parent
folder=base.parent
root=folder.parents[1]
def sha(p):return hashlib.sha256(p.read_bytes()).hexdigest().upper()
source=base/'pf-source-backups/20260924T041802Z/102.json'
source_data=json.loads(source.read_text(encoding='utf-8'))['data']
empty=base/'backups/20260924T035000Z/102.json'
assert json.loads(empty.read_text(encoding='utf-8'))['data']=={}
manifest=dict(status='proposal-not-executed',api='http://s101.local:18080/',playerId='102',source=str(source.relative_to(root)).replace('\\','/'),sourceSha256=sha(source),
emptyBaseline=str(empty.relative_to(root)).replace('\\','/'),emptyBaselineSha256=sha(empty),
transformValues=False,writeCount=len(source_data),entries=[dict(key=k,before='absent',utf8Bytes=len(v.encode('utf-8')),valueSha256=hashlib.sha256(v.encode('utf-8')).hexdigest().upper()) for k,v in sorted(source_data.items())],
restoreDeleteKeys=sorted(source_data),requireFreshEmptyGet=True,automaticRetry=False,restoreExecuted=False)
(base/'102-import-proposal.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
review=json.loads((base/'checkpoint2-review-input-hashes.json').read_text(encoding='utf-8'))
changes=[dict(path=x['path'],before=x['sha256'],after=sha(root/x['path'])) for x in review['files'] if sha(root/x['path'])!=x['sha256']]
expected={'Client/Assets/Scripts/Stages/LoginStage.cs','Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs','Client/Assets/Editor/KVGameplayLoopSetup.cs','CodexOutput/upserver/KVGameplayLoopTests/Preflight/Program.cs'}
assert {x['path'] for x in changes}==expected,changes
(base/'supplement-source-audit.json').write_text(json.dumps(dict(changedReviewedInputs=changes,newPreflightHelper='Preflight/PFSourceBackup.cs',schemaUnchanged=True),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
record='''
## 补充检查点2:M24/V-1完成,102实际来源已查清
固定equipment-chain最终9组28断言通过(supplement-equipment-verified.json),正式请求0、finally恢复成功;M24试验的cfg.Tables前置与GContext抽象方法编译遗漏已修正,首败全部保留。原Unity B3A 34/B2-20 83断言及许可15断言通过。新增Schema只读预检后的最终编译0错误273警告;本轮无CLI安全级别变更、无PlayMode、无打包。对比81项旧受审输入,仅3个批准源码及Preflight/Program.cs改变;新加PFSourceBackup.cs只读工具。Schema、31模块算法和底层均未改。
选择102作为最小候选:PF源45键,源文件SHA256 827F36084648449CEFAFB96B3CE62093DF70B8A87E33A526DA5B03A9C135A377;有20001/30003两根已有鱼竿,当前装备20001。101源55键需3处兼容,102只需1处。只读预检报告102 M04字段5 Malformed,根因VIPAdPackData.cdTime为正常业务产生的JSON null,而Schema.Advert强制String。没有改原档、清字段或放宽Schema。
完整单方法修订设计及102原45键导入/Unknown核对/精确恢复方案已落盘《阶段4最小真实玩法闭环-102数据准备与cdTime最小兼容方案-待审-2026-09-24.md》;机器清单102-import-proposal.json包含每键字节数/摘要/前值缺失及精确恢复集合,尚未执行。新Schema差异不在R1当前白名单,送这一处必要独立补审;不重审M24,不称R1关闭。本轮新增PF身份+合并UserData只读请求2次;s101成功请求累计仍为4,KV/PF写均0。
'''
for name in ['阶段4最小真实玩法闭环实施记录-2026-09-24.md','开发日志.md']:
with (folder/name).open('a',encoding='utf-8') as f:f.write(record)
entry='''## 当前有效状态:M24与V-1已完成;102原档一处cdTime兼容方案待审(2026-09-24)
补充独立审查81项hash一致,主会话已[逐条裁决](阶段4最小真实玩法闭环补充审查裁决-2026-09-24.md)。M24按批准范围完成登录期注册;固定编译宿主9组28断言通过并恢复,原B3A/31模块Unity回归及许可测试通过,未改CLI Restricted。R1真实闭环尚未完成。
已按用户101/102授权只读备份PF源并检查,来源不再未决:**选102现有45键,已有20001/30003鱼竿;唯一Schema错误是合法cdTime:null被严格字符串校验拒绝。** 101需要额外两类兼容,暂不选择。原KV空档、PF源与摘要保留,PF/KV写均0。
- [x] R1-C0、M24/V-1:补充裁决、注册、固定宿主及合成验证完成;不必再次审M24。
- [x] R1-数据准备:两账号PF只读备份/Schema诊断;确定102候选,45键摘要及恢复删除集合已列为proposal。
- [ ] **当前评审侧**:审[102数据与cdTime最小兼容方案](阶段4最小真实玩法闭环-102数据准备与cdTime最小兼容方案-待审-2026-09-24.md)(§5提示词),只批准一个Schema方法及具体数据准备范围。尚未修改Schema或导入数据。
- [ ] 主会话随后核hash/逐条裁决→局部兼容修订/专项回归→102导入前重新确认空KV→逐值导入核验→真实装备/重启恢复。
- [ ] V-4/R1-C3:真实身份发布、初始化所有写、关闭、UI装备与原票据、新进程恢复、独立实施复核及关闭,均未完成。
本轮游戏行为仅补M24注册;其余新增为固定合成测试和只读源预检,没有重构业务。最终编译0错误273警告,不因增量日志把工程称为零警告。运行许可不存在、未PlayMode、未打包。正式s101地址http://s101.local:18080/固定;Unity CLI Loop,不用UnitySkills。4B1/4B2/4B3A已关闭,整体4B3/G4、4C、热换号和P2-α真实迁移仍未通过。
### 以下为历史快照(当前以本节为准)
'''
for name in ['接手入口.md','开发状态.md','TODO.md']:
p=folder/name;old=p.read_text(encoding='utf-8');head,rest=old.split('\n',1)
if name!='接手入口.md':head='# '+('开发状态' if name=='开发状态.md' else 'TODO')+':R1接线与102存档兼容'
p.write_text(head+'\n\n'+entry+rest.lstrip(),encoding='utf-8')
p=folder/'阶段4B3当前TodoItem-2026-09-24.json';d=json.loads(p.read_text(encoding='utf-8'))
d.update(phase='R1-M24-V1-complete-102-cdTime-supplement-awaiting-review',nextResponsibleSide='independent-reviewer-cdTime-and-102-import-proposal',gameplayEnabled=False,formalRequests=4,pfReadOnlyLoginRequests=2,kvWrites=0,pfWrites=0)
d['implementationScope']='Original R1 and M24/fixed-host implemented; one Schema compatibility method pending review; real loop not verified'
for item in d['todoItem']:
if item['id']=='R1-M24-supplement':item.update(status='completed',detail='81项hash、M24/V1-4裁决,注册及固定9组28断言通过')
if item['id']=='R1-data-source':item.update(status='prepared-awaiting-compatibility-review',detail='已只读备份PF101/102,选102原45键;一处合法null解码兼容待审,尚未导入')
if item['id']=='R1-C1-C2':item.update(status='partially-complete',detail='M24和V-1已通过;真实档Schema兼容、真实加载/保存验收仍缺')
d['todoItem'].append(dict(id='R1-cdTime-compatibility',status='awaiting-independent-review',detail='仅Advert.cdTime显式JSON null保留;原字符串与其他类型检查不变,102原档不改值'))
p.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
paths={x['path'] for x in review['files']}
for name in ['阶段4最小真实玩法闭环最小补充独立审查-2026-09-24.md','阶段4最小真实玩法闭环补充审查裁决-2026-09-24.md','阶段4最小真实玩法闭环-102数据准备与cdTime最小兼容方案-待审-2026-09-24.md']:
paths.add('CodexOutput/upserver/'+name)
for name in ['Preflight/PFSourceBackup.cs','102-import-proposal.json','supplement-source-audit.json','supplement-equipment-verified.json','pf-source-schema.json','pf-source-backups/20260924T041802Z/102.json','backups/20260924T035000Z/102.json','source-inspection-compile.log']:
paths.add('CodexOutput/upserver/KVGameplayLoopTests/'+name)
files=[dict(path=p,sha256=sha(root/p)) for p in sorted(paths)]
(base/'102-compatibility-review-hashes.json').write_text(json.dumps(dict(scope='One Advert nullable field and 102 original 45-key proposal; no import executed',files=files),ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(json.dumps(dict(sourceKeys=len(source_data),changedReviewedInputs=len(changes),reviewHashes=len(files),proposalOnly=True),ensure_ascii=False))

View File

@@ -0,0 +1 @@
return await KVGameplayLoopSetup.RunValidationAsync("equipment-chain");

View File

@@ -0,0 +1,3 @@
var route=await KVSaveRouteValidation.RunAsync("fixed-route");
var load=await KVGameLoadValidation.RunAsync("B2-20");
return new{route,load,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,3 @@
using asap.core;
var p=GContext.container.Resolve<GameCore.PlayerData>();
return new {equipRod=(int)p.equipRodID,lv=(int)p.lv,gold=(ulong)p.gold,pearl=(int)p.pearl,diamond=(int)p.diamond,building=p.InfiniteBuildingData,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,88 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using game.PlayerDataStorage;
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || asap.core.GContext.Ready) throw new InvalidOperationException("Idle editor required.");
var asm=typeof(PlayerKVGameSessionBridge).Assembly;
var loop=asm.GetType("game.PlayerDataStorage.PlayerKVGameplayLoop",true);
var flags=BindingFlags.NonPublic|BindingFlags.Public|BindingFlags.Static;
var saved=loop.GetFields(flags).Where(f=>!f.IsInitOnly).ToDictionary(f=>f,f=>f.GetValue(null));
if((bool)loop.GetProperty("Enabled",flags).GetValue(null)) throw new InvalidOperationException("Real loop is active.");
var runType=asm.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost+Run",true);
var instanceFlags=BindingFlags.NonPublic|BindingFlags.Public|BindingFlags.Instance;
int checks=0,faults=0;
void Check(bool yes,string why) { checks++; if(!yes) throw new InvalidOperationException(why); }
void Set(string name,object value) { var f=loop.GetField(name,flags) ?? loop.GetField("<"+name+">k__BackingField",flags); f.SetValue(null,value); }
object Call(string name,params object[] args) { try { return loop.GetMethod(name,flags).Invoke(null,args); } catch(TargetInvocationException e) { throw e.InnerException; } }
var folder=Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests/unity-synthetic",Guid.NewGuid().ToString("N")));
Directory.CreateDirectory(folder);
var permitType=asm.GetType("game.PlayerDataStorage.PlayerKVLoopPermit",true);
var permit=Activator.CreateInstance(permitType,true);
permitType.GetProperty("PlayerId").SetValue(permit,"101");
permitType.GetProperty("AllowedKeys").SetValue(permit,new[]{"k","NewPlayerDataDic"});
object fixture=null;
async Task<PlayerKVGameSessionHandle> Start()
{
fixture=Activator.CreateInstance(runType,true);
var handler=(Func<CookieContainer,HttpMessageHandler>)Delegate.CreateDelegate(typeof(Func<CookieContainer,HttpMessageHandler>),fixture,runType.GetMethod("KV",instanceFlags));
var bridge=(PlayerKVGameSessionBridge)Activator.CreateInstance(typeof(PlayerKVGameSessionBridge),instanceFlags,null,new object[]{handler},null);
runType.GetField("bridge",instanceFlags).SetValue(fixture,bridge);
var handle=await (Task<PlayerKVGameSessionHandle>)runType.GetMethod("Open",instanceFlags).Invoke(fixture,new object[]{"101"});
Set("Enabled",true); Set("Ready",false); Set("failed",0); Set("Error",null); Set("handle",null);Set("collecting",null);
Set("Permit",permit);Set("RunPath",folder);Set("mainThread",Thread.CurrentThread.ManagedThreadId);Set("mainContext",SynchronizationContext.Current);
Set("onFault",new Action<string>(_=>faults++));Set("onClose",null);
Call("Publish",handle);
return handle;
}
async Task CloseFixture()
{
if(fixture!=null) await (Task)runType.GetMethod("Close",instanceFlags).Invoke(fixture,null);
fixture=null;
}
try
{
await Start();
Check((string)Call("Read","missing")==null,"valid missing key remains null");
var batch=Call("BeginEquip");
var nested=(PlayerKVEditResult)Call("Write","k","nested",true);
var primary=(PlayerKVEditResult)Call("Write","NewPlayerDataDic","whole-save",true);
((IDisposable)batch).Dispose();
await (Task)Call("ConfirmEquipAsync",batch,primary);
Check(nested.Ticket!=null && primary.Ticket!=null,"both exact tickets exist");
var remote=(Dictionary<string,Dictionary<string,string>>)runType.GetField("remote",instanceFlags).GetValue(fixture);
Check(remote["101"]["k"]=="nested" && remote["101"]["NewPlayerDataDic"]=="whole-save","nested and primary both remotely confirmed");
Check(faults==0,"successful equipment remains ready");
await CloseFixture();
await Start();
batch=Call("BeginEquip");
var superseded=(PlayerKVEditResult)Call("Write","k","first",true);
Call("Write","k","second",true);
primary=(PlayerKVEditResult)Call("Write","NewPlayerDataDic","whole-save",true);
((IDisposable)batch).Dispose();
bool rejected=false;try { await (Task)Call("ConfirmEquipAsync",batch,primary); } catch(InvalidOperationException e) { rejected=e.Message.Contains("Superseded"); }
Check(rejected && faults==1,"nested Superseded stops the entire equip chain");
Check(!(bool)loop.GetProperty("Ready",flags).GetValue(null),"fault synchronously revokes facade");
await CloseFixture();
await Start();
var before=(int)runType.GetField("writes",instanceFlags).GetValue(fixture);
rejected=false;try { Call("Write","unbacked-key","x",true); } catch(InvalidOperationException e) { rejected=e.Message.Contains("permission"); }
Check(rejected && (int)runType.GetField("writes",instanceFlags).GetValue(fixture)==before,"unauthorized key rejected before network");
await CloseFixture();
var closed=await Start();
await CloseFixture();
rejected=false;try { Call("Read","k"); } catch(InvalidOperationException) { rejected=true; }
Check(rejected,"failed stale read is not missing key");
Check(faults==3,"each separate synthetic run reports one fault");
return new{success=true,assertions=checks,formalRequests=0,synthetic=true,restored=true,folder};
}
finally
{
await CloseFixture(); // Do not restore over an undrained failure.
foreach(var pair in saved) pair.Key.SetValue(null,pair.Value);
}

View File

@@ -0,0 +1,3 @@
using asap.core;
await GContext.container.Resolve<IStageService>().SwitchStageAsync("QuitStage");
return KVGameplayLoopSetup.State();

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.PreparePermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/imports/20260924T061443Z/after.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","MMTest","Map_5_101","Map_5_102","MiniBPKeyNew","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack","epbdk","epdk"});

View File

@@ -0,0 +1,3 @@
var path="C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/imports/20260924T061443Z/after.json";
var raw=Newtonsoft.Json.Linq.JObject.Parse(System.IO.File.ReadAllText(path));
return KVGameplayLoopSetup.PreparePermit("102",path,((Newtonsoft.Json.Linq.JObject)raw["data"]).Properties().Select(p=>p.Name).ToArray());

View File

@@ -0,0 +1,3 @@
using asap.core;
var p=GContext.container.Resolve<GameCore.PlayerData>();
return new {equipRod=(int)p.equipRodID,lv=(int)p.lv,gold=(ulong)p.gold,pearl=(int)p.pearl,diamond=(int)p.diamond,building=p.InfiniteBuildingData,buildingLevel=p.InfiniteBuildingLevel,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.PreparePermit("102","C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T064749Z/102.json",new string[]{"AquariumEventDataNewKey","BattlePassRecordTask","BombGameCount","BuffDataList","BuyData","CampDataDic","ChainPackDataNew","DigCount","DigGridData","Event1v1BattleSlapData","EventBoostPackData","EventCubeMeltData","EventLuckGameRecord","EventLuckMagicPlayfabData","EventPartnerStatueData","FishHuntGameCount","FixedTimeShopDic","GuideSaveDataNew","IsOpenClubRed","LastPaymentTime","Leadboard_LastContinent","MMTest","Map_5_101","Map_5_102","MiniBPKeyNew","MonthlySignEvent","NewPlayerDataDic","NewRodList","OfferDiscoPartyManager","OfferStoryChain","OfferSweepPackData","Pack1A1Data","PiggyBankPackData","RodData","RodLevelData","SeasonStampData","ShopPackBuyCount","SoloDataKey","TargetBuffDataList","TransitionData","TriggerPackDataList","VIPAdPackData","WelcomeBack","epbdk","epdk"});

View File

@@ -0,0 +1,4 @@
var core=await KVGameLoadValidation.RunAsync("M01-M02-M21-M22-M24");
var full=await KVGameLoadValidation.RunAsync("B2-20");
var equipment=await KVGameplayLoopSetup.RunValidationAsync("equipment-chain");
return new{core,full,equipment,state=KVGameplayLoopSetup.State()};

View File

@@ -0,0 +1,3 @@
var first=KVGameplayLoopSetup.InspectPFBackup("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/101.json");
var second=KVGameplayLoopSetup.InspectPFBackup("C:/projects/ft/CodexOutput/upserver/KVGameplayLoopTests/pf-source-backups/20260924T041802Z/102.json");
return first+"\n"+second+"\n"+KVGameplayLoopSetup.State();

View File

@@ -0,0 +1 @@
return new { title = PlayFab.PlayFabSettings.TitleId, persistent = UnityEngine.Application.persistentDataPath };

View File

@@ -0,0 +1 @@
return KVGameplayLoopSetup.State();

View File

@@ -0,0 +1,48 @@
from pathlib import Path
import json
folder=Path(__file__).resolve().parents[1]
entry='''## 当前有效状态:R1方案已裁决,最小接线实施中;101/102现有KV均为空(2026-09-24)
独立审查接受R1范围和编码合同;主会话核对70项hash、6项新增路径,九项合同、2P2/3P3逐条裁决,见[实施批准](阶段4最小真实玩法闭环方案审查裁决与实施批准-2026-09-24.md)。R1不再处于方案待审。
用户已允许沿用101/102,要求不强行修改、选择合适方式。已在固定s101地址完成两账号登录和只读全量KV备份:均0键、无NewPlayerDataDic;KV Set/Delete均0。未启用运行许可,未PlayMode或打包,没有自动复制PF或制造初始档。
- [x] R1-C0:hash、逐条裁决、白名单实施批准、原始源码快照;用户账号授权及现有KV备份。
- [ ] R1-C1/C2:已编写许可/进程限制、正常会话接线、31对象绑定、facade、装备等待与关闭分支;Unity首轮编译0错误0警告,正在限定回归。尚未证明真实加载可用。
- [ ] R1-数据:既有101/102 KV空档;需确定完整初始存档来源,不自动迁移PF。
- [ ] R1-接线差异:M24射击对象在普通活动初始化时才注册,R1提前绑定需核查并准备精确补充裁决;尚未增加注册或改业务模块。
- [ ] R1-C3:真实界面装备、全部原票据Confirmed、Unity进程重启/新缓存恢复、独立实施复核和关闭,均未完成。
当前主会话继续白名单内验证和具体差异材料;完整实施记录见[实施记录](阶段4最小真实玩法闭环实施记录-2026-09-24.md)。4B1/4B2/4B3A已关闭;整个4B3/G4、4C、热换号、P2-α真实存量档迁移仍未通过。正式API固定http://s101.local:18080/,Unity CLI Loop,不使用UnitySkills。
### 以下为历史快照(当前以本节为准)
'''
for name in ['接手入口.md','开发状态.md','TODO.md']:
p=folder/name;old=p.read_text(encoding='utf-8-sig');head,rest=old.split('\n',1)
if name!='接手入口.md': head='# '+('开发状态' if name=='开发状态.md' else 'TODO')+':R1实施与数据预检'
p.write_text(head+'\n\n'+entry+rest.lstrip(),encoding='utf-8')
record='''
## 检查点0补充:账号授权与现有数据
用户回复:“可以沿用101 和 102 ,不必强行修改,按最合适的方式执行即可”。因此允许读取/备份这两个测试账号,后续运行按完整存档事实选择,不再重复索取账号许可;不把此话视为自动PF迁移或强制造档。
`Preflight/`只使用既有S101AccountSession登录及PlayerKVClient.GetAsync(null),没有Set/Delete实现。首次沙箱请求Network失败;相同程序经自动审批在非沙箱连接成功。20260924T035000Z两备份均0键,摘要见backups对应summary,101 SHA256 B4A65FE01518DE0D953F47ED770EEF88FFF434C58EF7F4889E21D669501016C8,102 SHA256 A4DA163C8E53A171AA74485F2DCA4FE92303F7E6A8573A5FFDBB90A711B1F402。当前无需恢复,因为未写KV。问询只读PF来源检查/现成KV备份/先接线三个选项,未收到选择前不迁移数据。
## 检查点1/2进行中:接线与初验
按白名单新增三源码及meta,修改11个既有文件;保留默认PF路径,复用原Bridge/Schema/31模块方法和装备序列化。尚无运行许可。许可13断言通过;Unity CLI沙箱EPERM后相同编译经自动审批成功,0错误0警告;State确认正确项目、未PlayMode、Enabled/Ready=false、无进程标记。默认服务宿主8组73断言通过。4B2离线75组1715断言通过。4A/4B1沙箱运行发生缓存写入相关失败,原日志保留,正在诊断;不能先称全回归通过。
核查发现M24 EventShootingRangeData未在LoginStage注册,而在FishingEventData.SetEvent7/PlayerData活动逻辑才注册。本轮绑定仅Resolve已有实例,缺失则拒绝,不擅自新增注册。此处会阻止完整31对象加载;准备精确差异材料,不能因编译通过声称真实闭环通过。
'''
for name in ['阶段4最小真实玩法闭环实施记录-2026-09-24.md','开发日志.md']:
with (folder/name).open('a',encoding='utf-8') as f:f.write(record)
with (folder/'阶段4最小真实玩法闭环方案审查裁决与实施批准-2026-09-24.md').open('a',encoding='utf-8') as f:
f.write('\n## 后续账号授权补充\n\n用户已允许沿用101/102,不必强行修改。主会话已只读备份现有KV,均空档;可继续白名单代码验证,但不自动PF迁移/造档。运行数据门仍以完整有效存档及实际允许key为准,不再重复请求账号许可。\n')
p=folder/'阶段4B3当前TodoItem-2026-09-24.json';d=json.loads(p.read_text(encoding='utf-8-sig'))
d.update(phase='R1-implementation-existing-KV-empty',nextResponsibleSide='main-session',gameplayEnabled=False,formalRequests=4,formalRequestDetail='Successful: 2 login + 2 full KV Get; initial sandbox login Network failure excluded; Set/Delete=0',R1ImplementationApproved=True)
for item in d['todoItem']:
if item['id'].startswith('R1'): item['status']='see-R1-checkpoints-below'
d['todoItem'] += [dict(id='R1-C0',status='completed',detail='70 hashes/6 absent paths; design ruling; account authorization and empty-KV backups'),dict(id='R1-C1-C2',status='in-progress',detail='Whitelist source compiled; regression and M24 registration gap audit pending'),dict(id='R1-C3',status='not-complete',detail='Full save source and M24 binding prerequisite unresolved; no real gameplay loop or implementation closure')]
p.write_text(json.dumps(d,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('Current entry/state/TODO and checkpoint logs updated.')