Files
ft/Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs

639 lines
34 KiB
C#

#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<PlayerKVLoopPermit>();
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<string> onFault;
static WriteBatch collecting;
static int mainThread, failed;
static SynchronizationContext mainContext;
static readonly object auditGate=new object();
internal static void Attach(Action<string> 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<PlayerKVEditResult> Edits=new List<PlayerKVEditResult>();
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
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<string> VerifyEquipmentForEditorAsync()
{
if (UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().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<cfg.Tables>(tables);
EquipmentContextAccess.Use(container);
using (container.InjectionScope())
container.Register<EventShootingRangeData>().AsSingleton();
var shooting = container.Resolve<EventShootingRangeData>();
Check(shooting != null && ReferenceEquals(shooting, container.Resolve<EventShootingRangeData>()), "M24 container resolves one instance");
shooting.LoadData("{\"EventId\":11,\"AmmoCount\":7}");
var shootingData = JObject.FromObject(Module02OriginalLoadTests.Field<object>(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<GContext>().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<Dictionary<string, string>>();
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<string,string> Remote=new Dictionary<string,string>{{"k","original"},{"NewPlayerDataDic","original-save"}};
internal readonly TaskCompletionSource<bool> SetEntered=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
internal readonly TaskCompletionSource<bool> ReleaseSet=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
internal int Sets,Auth,Faults;
internal bool Unauthorized,LoseResponse,HoldSet,PartialCommit;
sealed class Handler:HttpMessageHandler
{
readonly Func<HttpRequestMessage,Task<HttpResponseMessage>> send;
internal Handler(Func<HttpRequestMessage,Task<HttpResponseMessage>> send) { this.send=send; }
protected override Task<HttpResponseMessage> 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<PlayerKVGameSessionHandle> 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