#if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Security.Cryptography; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; #if !SERVICE_HOST using System.Threading; using System.Threading.Tasks; using System.Net; using System.Net.Http; using asap.core; using GameCore; using game.PlayerDataStorage; using UnityEngine; #endif namespace game.PlayerDataStorage { // Editor-only permission to use an existing, backed-up disposable save. Never a migration tool. internal sealed class PlayerKVLoopPermit { public int Version { get; set; } public bool Enabled { get; set; } public string PlayerId { get; set; } public string Api { get; set; } public string BackupPath { get; set; } public string BackupSha256 { get; set; } public string[] AllowedKeys { get; set; } [JsonProperty(NullValueHandling=NullValueHandling.Ignore)] public string Operation { get; set; } [JsonIgnore] internal bool AllowsFishing => (Version==2 && Operation=="fishing-consume-once") || AllowsReward; [JsonIgnore] internal bool AllowsReward => Version==3 && Operation=="fishing-reward-once"; internal static string Hash(byte[] bytes) { using (var sha=SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-", ""); } internal static PlayerKVLoopPermit Read(string path) { try { var document=JObject.Parse(File.ReadAllText(path)); var p=document.ToObject(); if(p==null || (p.Version!=1 && p.Version!=2 && p.Version!=3) || (p.Version==1 && document.Property("Operation",StringComparison.Ordinal)!=null) || (p.Version==2 && p.Operation!="equipment" && p.Operation!="fishing-consume-once") || (p.Version==3 && p.Operation!="fishing-reward-once") || !p.Enabled || (p.PlayerId!="101" && p.PlayerId!="102") || p.Api!="http://s101.local:18080/" || p.AllowedKeys==null || p.AllowedKeys.Length==0 || p.AllowedKeys.Distinct(StringComparer.Ordinal).Count()!=p.AllowedKeys.Length || !p.AllowedKeys.Contains("NewPlayerDataDic")) throw new InvalidOperationException("Invalid loop permission."); var root=Path.GetFullPath(Path.GetDirectoryName(path))+Path.DirectorySeparatorChar; var backup=Path.GetFullPath(p.BackupPath); if(!backup.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup must be inside loop workspace."); var bytes=File.ReadAllBytes(backup); if(!string.Equals(Hash(bytes),p.BackupSha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup hash mismatch."); var saved=JObject.Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF')); var data=saved["data"] as JObject; if((string)saved["playerId"]!=p.PlayerId || (string)saved["api"]!=p.Api || data==null || p.AllowedKeys.Any(k=>string.IsNullOrEmpty(k) || k.Length>128 || data.Property(k,StringComparison.Ordinal)==null) || data.Properties().Any(v=>v.Value.Type!=JTokenType.String)) throw new InvalidOperationException("Backup identity or key permission mismatch."); return p; } catch(InvalidOperationException) { throw; } catch(Exception e) { throw new InvalidOperationException("Cannot validate loop permission.",e); } } internal static string Claim(string root,string processIdentity) { if(string.IsNullOrEmpty(processIdentity) || processIdentity.Any(c=>!char.IsLetterOrDigit(c) && c!='-' && c!='_')) throw new InvalidOperationException("Invalid process identity."); var folder=Path.Combine(root,"run-control"); Directory.CreateDirectory(folder); var path=Path.Combine(folder,processIdentity+".claimed"); using(var file=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read)) { var bytes=Encoding.UTF8.GetBytes(DateTime.UtcNow.ToString("O")); file.Write(bytes,0,bytes.Length); file.Flush(true); } return path; } } #if !SERVICE_HOST internal static partial class PlayerKVGameplayLoop { internal static bool Enabled { get; private set; } internal static bool Ready { get; private set; } internal static string Error { get; private set; } internal static PlayerKVLoopPermit Permit { get; private set; } internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests")); internal static string RunPath { get; private set; } internal static string CacheRoot => Path.Combine(RunPath,"cache"); static PlayerKVGameSessionHandle handle; static Action onFault; static Func onClose; static WriteBatch collecting; static int mainThread, failed; static SynchronizationContext mainContext; static readonly object auditGate=new object(); internal static void BeforeBootstrap() { var path=Path.Combine(Root,"permit.json"); if(!File.Exists(path)) return; // A malformed or active permit must never silently fall back to PF. Enabled=true; if(JObject.Parse(File.ReadAllText(path))["Enabled"]?.Value()==false) { Enabled=false; return; } if(PlayFabMgr.HasLiveInstanceForKVLoad) throw new InvalidOperationException("Cannot switch a running storage backend."); Permit=PlayerKVLoopPermit.Read(path); using(var process=System.Diagnostics.Process.GetCurrentProcess()) { var identity=process.Id+"_"+process.StartTime.ToUniversalTime().Ticks; PlayerKVLoopPermit.Claim(Root,identity); RunPath=Path.Combine(Root,"runs",identity); Directory.CreateDirectory(RunPath); } mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current ?? throw new InvalidOperationException("Unity main context required."); GContext.container.Resolve().Set("SaveBackend","kv-loop"); GContext.container.Resolve().Set("CustomID",Permit.PlayerId); Audit("claim",new{Permit.PlayerId,Permit.BackupSha256}); } internal static void Attach(Action fault,Func close) { onFault=fault; onClose=close; } internal static void Publish(PlayerKVGameSessionHandle value) { if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId) throw new InvalidOperationException("Loop handle cannot be published."); handle=value; Ready=true; Audit("ready",new{value.PlayerId,value.OwnerInstanceId,value.Epoch}); } internal static PlayerKVGameSessionHandle Capture() { if(!Ready || failed!=0 || handle==null || Thread.CurrentThread.ManagedThreadId!=mainThread) throw Stop("Loop not writable on this thread or requires process restart."); return handle; } internal static string Read(string key) { var result=PlayFabMgr.ReadKV(Capture(),key); if(!result.Success) throw Stop("Read: "+result.Error); return result.Found==true ? result.Value : null; } internal static PlayerKVEditResult Write(string key,string value,bool remote) { var owner=Capture(); if(!Permit.AllowedKeys.Contains(key,StringComparer.Ordinal)) throw Stop("Write outside backed-up permission: "+key); CheckRewardWrite(key,value); var valueHash=value==null?null:PlayerKVLoopPermit.Hash(Encoding.UTF8.GetBytes(value)); Audit("write-attempt",new{key,remote,valueHash}); var result=PlayFabMgr.WriteKV(owner,key,value,remote); if(!result.Accepted) throw Stop("Write: "+result.StateResult.Error+" uncertain="+result.StateResult.LocalCommitMayHaveSucceeded); if(collecting!=null) { collecting.Edits.Add(result); collecting.Records.Add(new WriteRecord(key,remote,result,valueHash)); } Audit("accepted",new{key,remote,result.StateResult.AttemptSequence,result.StateResult.Revision}); return result; } internal sealed class WriteBatch : IDisposable { internal readonly PlayerKVGameSessionHandle Owner; internal readonly List Edits=new List(); internal readonly List Records=new List(); internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; } public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; } } internal static WriteBatch BeginEquip() { if(Permit?.AllowsFishing==true) throw Stop("Equipment is outside this fishing permit."); if(collecting!=null) throw Stop("Reentrant equipment action."); return collecting=new WriteBatch(Capture()); } internal sealed class WriteRecord { internal readonly string Key,ValueHash; internal readonly bool Remote; internal readonly PlayerKVEditResult Edit; internal WriteRecord(string key,bool remote,PlayerKVEditResult edit,string valueHash) { Key=key; Remote=remote; Edit=edit; ValueHash=valueHash; } } internal sealed class FishingAction { internal readonly object Actor; internal readonly PlayerKVGameSessionHandle Owner; internal bool Consuming,WritesStarted,Cancelled,Confirmed,Continued; internal FishingAction(object actor,PlayerKVGameSessionHandle owner) { Actor=actor; Owner=owner; } } static FishingAction fishingAction; internal static FishingAction BeginFishing(object actor) { if(Permit?.AllowsFishing!=true) throw Stop("A fishing-consume-once permit is required before casting."); if(fishingAction!=null) { Audit("duplicate-suppressed",new{point="cast"}); return null; } return fishingAction=new FishingAction(actor,Capture()); } internal static bool FishingCurrent(FishingAction action) => action!=null && ReferenceEquals(action,fishingAction) && !action.Cancelled && Ready && failed==0 && ReferenceEquals(action.Owner,handle); internal static void CancelFishing(FishingAction action,string reason) { if(action==null || action.Cancelled) return; CancelReward(action.Actor,reason); action.Cancelled=true; if(Ready && !action.Continued) Fault("Fishing action cancelled: "+reason); } internal static bool ClaimFishingConsumption(FishingAction action) { if(!FishingCurrent(action)) throw Stop("Fishing action is no longer current."); if(action.Consuming) { Audit("duplicate-suppressed",new{point="consume"}); return false; } action.Consuming=true; return true; } internal static WriteBatch BeginFishingWrites(FishingAction action) { if(!FishingCurrent(action) || !action.Consuming || action.WritesStarted || collecting!=null || !ReferenceEquals(Capture(),action.Owner)) throw Stop("Fishing write batch is not admissible."); action.WritesStarted=true; return collecting=new WriteBatch(action.Owner); } internal static async Task ConfirmFishingAsync(FishingAction action,WriteBatch batch,PlayerKVEditResult primary) { try { if(!FishingCurrent(action) || !ReferenceEquals(batch.Owner,action.Owner) || ReferenceEquals(collecting,batch)) throw Stop("Fishing batch must be sealed on its original action."); var last=batch.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal); if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) || last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets."); using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30))) { var flush=await PlayFabMgr.FlushKVAsync(batch.Owner,timeout.Token); if(!FishingCurrent(action) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Fishing flush: "+flush.TransportError); foreach(var record in batch.Records) { var ticket=record.Edit.Ticket; if(ticket==null) continue; // Intermediate localOnly is not a remote acknowledgement. var final=last[record.Key].Edit.Ticket; if(ticket.OwnerInstanceId!=batch.Owner.OwnerInstanceId || ticket.Epoch!=batch.Owner.Epoch || ticket.Key!=record.Key || final.OwnerInstanceId!=ticket.OwnerInstanceId || final.Epoch!=ticket.Epoch) throw Stop("Fishing ticket identity mismatch."); var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,ticket,timeout.Token); if(!FishingCurrent(action)) throw Stop("Fishing action changed while waiting."); bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(ticket,final) && final.Sequence>ticket.Sequence; if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Fishing ticket: "+result.Status); } } action.Confirmed=true; Audit("fishing-consume-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash}),batch.Owner.OwnerInstanceId,batch.Owner.Epoch}); } catch(Exception e) { Fault(e.Message); throw; } } internal static void ContinueFishing(FishingAction action,Action continuation) { if(!FishingCurrent(action) || !action.Confirmed) throw Stop("Fishing cannot continue before confirmation."); if(action.Continued) { Audit("duplicate-suppressed",new{point="continue"}); return; } action.Continued=true; continuation(); Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch}); } internal static async Task ConfirmEquipAsync(WriteBatch batch,PlayerKVEditResult primary) { if(primary?.Ticket==null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(),batch.Owner)) throw Stop("Missing original equipment ticket."); var flush=await PlayFabMgr.FlushKVAsync(batch.Owner); if(flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Equipment flush: "+flush.TransportError); foreach(var edit in batch.Edits) { if(edit.Ticket==null) continue; // localOnly is audited, never claimed as remotely confirmed. var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket); if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status); } if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed."); Audit("equipment-confirmed",new{tickets=batch.Edits.Count(e=>e.Ticket!=null)}); } internal static async Task FlushObservedAsync() { if(!Ready) return; try { var result=await PlayFabMgr.FlushKVAsync(Capture()); if(result.Unknown>0 || result.Blocked>0 || result.TransportError!=PlayerKVError.None) throw Stop("Periodic flush: "+result.TransportError); } catch(Exception e) { Fault(e.Message); } } internal static void Revoke() { Ready=false; } internal static Task CloseAsync() { Revoke(); return onClose?.Invoke() ?? Task.CompletedTask; } internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); } internal static void Fault(string message) { Ready=false; if(Interlocked.Exchange(ref failed,1)!=0) return; Error=message; try { Audit("fault",new{message}); } catch(Exception e) { Debug.LogException(e); } Debug.LogError("KV loop stopped: "+message); if(Thread.CurrentThread.ManagedThreadId==mainThread) onFault?.Invoke(message); else mainContext?.Post(_=>onFault?.Invoke(message),null); } internal static void Audit(string kind,object detail) { if(RunPath==null) return; lock(auditGate) File.AppendAllText(Path.Combine(RunPath,"events.jsonl"),JsonConvert.SerializeObject(new{at=DateTime.UtcNow,kind,detail})+Environment.NewLine); } // Fixed, compiled Editor test. No caller-supplied URL, code, reflection target, or real run permit. static int validationRunning; static object validationCleanupFailure; internal static async Task VerifyEquipmentForEditorAsync() { if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null) throw new InvalidOperationException("An idle editor without a real loop permit is required."); if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation already running."); var original=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext); var folder=Path.Combine(Root,"fixed-equipment-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N")); EquipmentFixture fixture=null; Task pendingConfirmation=null; string result=null; int assertions=0, groups=0; void Check(bool ok,string reason) { assertions++; if(!ok) throw new InvalidOperationException("Equipment validation: "+reason); } async Task Start() { if(fixture!=null) await fixture.Close(); fixture=new EquipmentFixture(Path.Combine(folder,groups.ToString())); var owner=await fixture.Open(); Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; Permit=new PlayerKVLoopPermit{PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}}; RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current; Attach(_=>fixture.Faults++,fixture.Close); Publish(owner); groups++; } async Task Reject(Task task,string contains) { bool rejected=false; try { await task; } catch(InvalidOperationException e) { rejected=e.Message.Contains(contains); } Check(rejected,"expected rejection: "+contains); Check(!Ready && fixture.Faults==1,"one fault revokes facade"); } try { if(!PlayerKVGameLoadGuard.TryEnter(out var probe)) throw new InvalidOperationException("Load guard occupied."); probe.Dispose(); Directory.CreateDirectory(folder); await Start(); Check(Read("missing")==null,"valid missing key remains null"); var batch=BeginEquip(); // This nested callback represents the synchronous Publish subscriber in the equipment chain. Action nested=()=>Write("k","nested",true); nested(); var primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose(); Check(batch.Edits.Count==2 && ReferenceEquals(primary,batch.Edits[1]),"all original edits collected"); Check(fixture.Sets==0,"write acceptance is local only"); await ConfirmEquipAsync(batch,primary); foreach(var edit in batch.Edits) Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed"); Check(fixture.Remote["k"]=="nested" && fixture.Remote["NewPlayerDataDic"]=="whole-save" && Ready && fixture.Faults==0,"complete batch committed"); await Start(); batch=BeginEquip(); var first=Write("k","first",true); Write("k","second",true); primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose(); await Reject(ConfirmEquipAsync(batch,primary),"Superseded"); Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced"); await Start(); batch=BeginEquip(); Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose(); fixture.HoldSet=true; var confirmation=ConfirmEquipAsync(batch,primary); pendingConfirmation=confirmation; var entered=await Task.WhenAny(fixture.SetEntered.Task,confirmation,Task.Delay(5000)); Check(ReferenceEquals(entered,fixture.SetEntered.Task),"first set entered"); var late=Write("late","later-write",true); Check(batch.Edits.Count==2,"later write excluded from synchronous batch"); fixture.ReleaseSet.TrySetResult(true); await confirmation; pendingConfirmation=null; Check(!fixture.Remote.ContainsKey("late"),"flush watermark excludes later write"); await PlayFabMgr.FlushKVAsync(batch.Owner); Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush"); await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose(); fixture.Unauthorized=true; await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); Check(fixture.Auth==1 && fixture.Sets==1,"401 never reauthenticates or retries"); await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","lost-response",true); batch.Dispose(); fixture.LoseResponse=true; await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); Check(fixture.Remote["NewPlayerDataDic"]=="lost-response" && fixture.Sets==1,"unknown remote commit is not replayed"); Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved"); await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose(); await fixture.Close(); await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush"); Check(fixture.Sets==0,"close does not flush pending writes"); await Start(); bool denied=false; try { Write("unbacked-key","x",true); } catch(InvalidOperationException e) { denied=e.Message.Contains("permission"); } Check(denied && fixture.Sets==0 && !Ready,"key permission checked before writes"); await Start(); await fixture.Close(); denied=false; try { Read("k"); } catch(InvalidOperationException) { denied=true; } Check(denied && !Ready,"stale read cannot become missing"); var originalContainer=GContext.container; var container=new Container(); try { var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json")))); container.RegisterInstance(tables); EquipmentContextAccess.Use(container); using(container.InjectionScope()) container.Register().AsSingleton(); var shooting=container.Resolve(); Check(shooting!=null && ReferenceEquals(shooting,container.Resolve()),"M24 container resolves one instance"); shooting.ResetForKVLoad(); Check(!shooting.KVLoadPresent,"M24 can enter the existing load transaction without activation"); } finally { EquipmentContextAccess.Use(originalContainer); container.Dispose(); } Check(ReferenceEquals(GContext.container,originalContainer),"M24 global container restored"); groups++; result=JsonConvert.SerializeObject(new{success=true,groups,assertions,formalRequests=0,synthetic=true,restored=true,folder}); } finally { try { if(fixture!=null) await fixture.Close(); if(pendingConfirmation!=null) try { await pendingConfirmation; } catch(Exception e) { Debug.LogWarning("Observed failed synthetic confirmation during cleanup: "+e.GetType().Name); } (Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext)=original; } catch { validationCleanupFailure=fixture; throw; } finally { Interlocked.Exchange(ref validationRunning,0); } } File.WriteAllText(Path.Combine(folder,"result.json"),result); return result; } sealed class EquipmentContextAccess:GContext { protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context."); internal static void Use(Container value) { container=value; } } internal static string InspectPFBackupForEditor(string path) { if(Enabled || validationRunning!=0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll().Length!=0) throw new InvalidOperationException("Idle editor required for source inspection."); path=Path.GetFullPath(path); var allowed=Path.GetFullPath(Path.Combine(Root,"pf-source-backups"))+Path.DirectorySeparatorChar; if(!path.StartsWith(allowed,StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Expected local PF source backup."); var bytes=File.ReadAllBytes(path); var source=JObject.Parse(Encoding.UTF8.GetString(bytes)); var id=(string)source["customId"]; if((string)source["source"]!="PlayFab" || (string)source["titleId"]!="108EE8" || (id!="101" && id!="102")) throw new InvalidOperationException("Source identity mismatch."); var raw=source["data"].ToObject>(); var originalContainer=GContext.container; var isolated=new Container(); try { var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData")); var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json")))); isolated.RegisterInstance(tables); EquipmentContextAccess.Use(isolated); var now=DateTime.UtcNow; var snapshot=PlayerKVGameplayBindings.Snapshot(now); // Diagnostic-only context: this is NOT a real KV Prepare/Open and cannot publish a handle. var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,id,1,ownerId:"offline-PF-candidate",data:raw); var account=new PlayerKVAccountDescriptor(id,1,0,false,DateTime.SpecifyKind(DateTime.MinValue,DateTimeKind.Utc),now); var identity=new PlayerKVIdentitySnapshot((string)source["playFabId"],false,PlayerKVIdentityChannel.none,Array.Empty()); var decoded=PlayerKVSaveSchema.Decode(prepared,account,id,1,true,snapshot,snapshot.Version,identity); return JsonConvert.SerializeObject(new{sourceOnly=true,syntheticDescriptor=true,usesLocalUtc=true,customId=id,path,sha256=PlayerKVLoopPermit.Hash(bytes),keys=raw.Count, verdict=decoded.Verdict.ToString(),errors=decoded.Diagnostics.TotalErrors, details=decoded.Diagnostics.Details.Select(d=>new{module=d.Module,field=d.Field,code=d.Code.ToString()}), configuredRods=decoded.Data?.Module(3).RodLevels.Keys.ToArray(), gameplayPublished=false,writes=0}); } finally { EquipmentContextAccess.Use(originalContainer); isolated.Dispose(); } } sealed class EquipmentFixture { static readonly Uri Api=new Uri("http://kv-loop.invalid/"); readonly string cache; readonly game.Account.S101AccountSession session; readonly PlayerKVGameSessionBridge bridge; internal readonly Dictionary Remote=new Dictionary{{"k","original"},{"NewPlayerDataDic","original-save"}}; internal readonly TaskCompletionSource SetEntered=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal readonly TaskCompletionSource ReleaseSet=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); internal int Sets,Auth,Faults; internal bool Unauthorized,LoseResponse,HoldSet,PartialCommit; sealed class Handler:HttpMessageHandler { readonly Func> send; internal Handler(Func> send) { this.send=send; } protected override Task SendAsync(HttpRequestMessage request,CancellationToken token) => send(request); } static HttpResponseMessage Json(object body) => new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(JsonConvert.SerializeObject(body))}; internal EquipmentFixture(string cache) { this.cache=cache; session=new game.Account.S101AccountSession(Api,jar=>new Handler(request=> { if(request.RequestUri!=new Uri(Api,"account/Login")) throw new InvalidOperationException("Unexpected synthetic login target."); Auth++; jar.Add(Api,new Cookie("sid","101")); return Task.FromResult(Json(new{state=0,internalId=101,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"})); })); bridge=new PlayerKVGameSessionBridge(jar=>new Handler(async request=> { if(request.RequestUri.Host!=Api.Host || jar.GetCookies(Api)["sid"]?.Value!="101") throw new InvalidOperationException("Unexpected synthetic KV identity."); if(request.RequestUri.AbsolutePath=="/playerdata/Get") return Json(new{data=Remote}); if(request.RequestUri.AbsolutePath!="/playerdata/Set") throw new InvalidOperationException("Unexpected synthetic operation."); Sets++; SetEntered.TrySetResult(true); if(HoldSet) await ReleaseSet.Task; if(Unauthorized) return new HttpResponseMessage(HttpStatusCode.Unauthorized); var body=JObject.Parse(await request.Content.ReadAsStringAsync()); var values=((JObject)body["data"]).Properties(); foreach(var pair in PartialCommit?values.Take(1):values) Remote[pair.Name]=(string)pair.Value; if(LoseResponse) throw new HttpRequestException("Synthetic response lost after commit."); return Json(new{}); })); } internal async Task Open() { if(!(await session.LoginAsync("101")).Success) throw new InvalidOperationException("Synthetic login failed."); var opened=await bridge.OpenAsync(session,cache,"101",Api,1,e=>e==1); if(!opened.Success) throw new InvalidOperationException("Synthetic open failed: "+opened.Error); return opened.Handle; } internal async Task Close() { ReleaseSet.TrySetResult(true); await bridge.CloseAsync(); session.Dispose(); } } } #endif } #if !SERVICE_HOST namespace game { public partial class PlayFabUserService { PlayerKVGameSessionBridge loopBridge; PlayerKVGameLoadGuard loopGuard; Task loopClosing; TaskCompletionSource loopSettled; bool loopAttempted, loopStopping; internal bool KVLoopLoading { get; private set; } async Task LoginKVGameplayLoopAsync(Action progress,int generation) { if(loopAttempted || loopStopping || CustomId!=PlayerKVGameplayLoop.Permit.PlayerId) return ELoginStep.Api; loopAttempted=true; loopSettled=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); loopBridge=new PlayerKVGameSessionBridge(); PlayerKVGameplayLoop.Attach(message=> { _=StopKVGameplayLoopAsync(); var main=UnityEngine.Application.isPlaying; if(main) _=ExitKVGameplayLoopAsync(); },StopKVGameplayLoopAsync); try { if(customServerMgr.ApiBaseUri.AbsoluteUri!=PlayerKVGameplayLoop.Permit.Api) throw new InvalidOperationException("KV loop requires the fixed s101 API."); if(!await LoginPlayFab(generation) || generation!=logoutGeneration) throw new InvalidOperationException("PF identity login failed/stale."); if(!await LoginApiServer() || generation!=logoutGeneration) throw new InvalidOperationException("API login failed/stale."); if(InternalId!=0 || IsLogin) throw new InvalidOperationException("Gameplay identity published before load."); var opened=await loopBridge.OpenAsync(apiSession,PlayerKVGameplayLoop.CacheRoot,CustomId,customServerMgr.ApiBaseUri, generation,e=>!loopStopping && e==logoutGeneration,LoginLifetime); if(!opened.Success) throw new InvalidOperationException("KV open: "+opened.Error); var read=opened.Handle.ReadLocalSnapshot(); if(!read.Success) throw new InvalidOperationException("KV snapshot: "+read.Error); var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,apiSession.PlayerId,generation, syncStatus:opened.Handle.Status,ownerId:opened.Handle.OwnerInstanceId,data:read.Data); var account=new PlayerKVAccountDescriptor(apiSession.PlayerId,generation,apiSession.InternalId, apiSession.NewlyCreated,apiSession.CreateTimeUtc,apiSession.ServerTimeUtc); var snapshot=PlayerKVGameplayBindings.Snapshot(apiSession.ServerTimeUtc); var identity=new PlayerKVIdentitySnapshot(UserId,config.Get("NewlyCreated",false),PlayerKVIdentityChannel.none,Array.Empty()); var decoded=PlayerKVSaveSchema.Decode(prepared,account,CustomId,generation,!loopStopping && generation==logoutGeneration,snapshot,snapshot.Version,identity); if(decoded.Data==null) throw new InvalidOperationException("KV schema: "+decoded.Verdict); if(!PlayerKVGameLoadGuard.TryEnter(out loopGuard)) throw new InvalidOperationException("Load guard occupied."); KVLoopLoading=true; var transaction=PlayerKVGameplayBindings.Bind(this); var applied=transaction.Apply(decoded.Data,()=>!loopStopping && generation==logoutGeneration, ()=>loopGuard.IsCurrent && loopGuard.Violations==0,()=>{}); if(applied!=PlayerKVApplyError.None) throw new InvalidOperationException("KV apply: "+applied); KVLoopLoading=false; loopGuard.Dispose(); loopGuard=null; PlayerKVGameplayLoop.Publish(opened.Handle); PlayFabMgr.Instance.StartKVGameplayLoopSync(); InternalId=apiSession.InternalId; GContext.Publish(new EvtAPISvrLoginSuccess{playerId=apiSession.PlayerId,internalId=InternalId}); OnInitData?.Invoke(decoded.Data.Raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal)); SetMMTestValue(); if(!PlayerKVGameplayLoop.Ready || loopStopping || generation!=logoutGeneration) throw new InvalidOperationException("Initialization revoked loop."); progress?.Invoke(2); return ELoginStep.Done; } catch(Exception e) { PlayerKVGameplayLoop.Fault(e.Message); return ELoginStep.Api; } finally { KVLoopLoading=false; loopSettled.TrySetResult(true); } } async Task ExitKVGameplayLoopAsync() { try { await GContext.container.Resolve().SwitchStageAsync("QuitStage"); } catch(Exception e) { Debug.LogException(e); } } internal Task StopKVGameplayLoopAsync() { if(loopClosing!=null) return loopClosing; loopStopping=true; PlayerKVGameplayLoop.Revoke(); logoutGeneration++; apiLoginGeneration++; InternalId=0; PlayFabMgr.Instance?.StopKVGameplayLoopSync(); var completion=new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); loopClosing=completion.Task; PlayerKVShutdownRegistry.Process.Register(loopClosing); try { loginLifetime.Cancel(); } catch(Exception e) { Debug.LogException(e); } var drain=loopBridge?.CloseAsync() ?? Task.CompletedTask; _=FinishKVGameplayLoopAsync(drain,completion); return loopClosing; } async Task FinishKVGameplayLoopAsync(Task drain,TaskCompletionSource completion) { try { if(loopSettled!=null) await loopSettled.Task; await drain; if(loopBridge!=null) await loopBridge.CloseAsync(); apiSession?.Dispose(); apiSession=null; customServerMgr.ClearAccountSession(); loopGuard?.Dispose(); loopGuard=null; UserId=null; DisplayName=null; AvatarUrl=null; InternalId=0; PlayFab.PlayFabClientAPI.ForgetAllCredentials(); PlayerKVGameplayLoop.Audit("closed",new{requiresProcessRestart=true}); completion.TrySetResult(true); } catch(Exception e) { completion.TrySetException(e); Debug.LogException(e); } } } } #endif #endif