#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; } static PlayerKVGameSessionHandle handle; static Action onFault; static WriteBatch collecting; static int mainThread, failed; static SynchronizationContext mainContext; static readonly object auditGate=new object(); internal static void Attach(Action fault) { onFault = fault; } 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=KVMgr.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=KVMgr.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 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))) { 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 KVMgr.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 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, 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; 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()); } 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."); } foreach (var edit in batch.Edits) { // localOnly is audited, never claimed as remotely confirmed. if (edit.Ticket == null) { continue; } var result = await KVMgr.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) }); } 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++); 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"); _ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation. await ConfirmEquipAsync(batch, primary); foreach (var edit in batch.Edits) Check((await KVMgr.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(); _ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation. await Reject(ConfirmEquipAsync(batch, primary), "Superseded"); Check((await KVMgr.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; _ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation. 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 KVMgr.SyncKVAsync(batch.Owner); Check((await KVMgr.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; _ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation. await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket"); 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; _ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation. await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket"); Check(fixture.Remote["NewPlayerDataDic"] == "lost-response" && fixture.Sets == 1, "unknown remote commit is not replayed"); Check((await KVMgr.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 ticket"); 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.LoadData("{\"EventId\":11,\"AmmoCount\":7}"); var shootingData = JObject.FromObject(Module02OriginalLoadTests.Field(shooting, "_data")); Check((int)shootingData["EventId"] == 11 && (int)shootingData["AmmoCount"] == 7, "M24 registered instance uses original load method"); } 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, 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 actual = Module02OriginalLoadTests.InspectRaw(raw); return JsonConvert.SerializeObject(new { sourceOnly = true, usesLocalUtc = true, customId = id, path, sha256 = PlayerKVLoopPermit.Hash(bytes), keys = raw.Count, originalLoad = JObject.Parse(actual), gameplayPublished = false, formalRequests = 0 }); } 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); Exception closeError = null; try { await bridge.CloseAsync(); } catch (Exception error) { closeError = error; throw; } finally { try { session.Dispose(); } catch (Exception disposeError) { if (closeError != null) { throw new AggregateException(closeError, disposeError); } throw; } } } } } #endif } #endif