保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
622 lines
40 KiB
C#
622 lines
40 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; }
|
|
internal static string CacheRoot => Path.Combine(RunPath,"cache");
|
|
static PlayerKVGameSessionHandle handle;
|
|
static Action<string> onFault;
|
|
static Func<Task> 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<bool>()==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<IConfig>().Set("SaveBackend","kv-loop");
|
|
GContext.container.Resolve<IConfig>().Set("CustomID",Permit.PlayerId);
|
|
Audit("claim",new{Permit.PlayerId,Permit.BackupSha256});
|
|
}
|
|
internal static void Attach(Action<string> fault,Func<Task> 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=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 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 KVMgr.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 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 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 KVMgr.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 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)});
|
|
}
|
|
internal static async Task FlushObservedAsync()
|
|
{
|
|
if(!Ready) return;
|
|
try
|
|
{
|
|
var result=await KVMgr.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<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,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 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();
|
|
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;
|
|
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.FlushKVAsync(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;
|
|
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 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 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<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.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<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 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<cfg.Tables>(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<PlayerKVIdentityChannel>());
|
|
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<PlayerKVFishState>(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<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);
|
|
await bridge.CloseAsync();
|
|
session.Dispose();
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if !SERVICE_HOST
|
|
namespace game
|
|
{
|
|
public partial class PlayFabUserService
|
|
{
|
|
PlayerKVGameSessionBridge loopBridge;
|
|
PlayerKVGameLoadGuard loopGuard;
|
|
Task loopClosing;
|
|
TaskCompletionSource<bool> loopSettled;
|
|
bool loopAttempted, loopStopping;
|
|
internal bool KVLoopLoading { get; private set; }
|
|
|
|
async Task<ELoginStep> LoginKVGameplayLoopAsync(Action<int> progress,int generation)
|
|
{
|
|
if(loopAttempted || loopStopping || CustomId!=PlayerKVGameplayLoop.Permit.PlayerId) return ELoginStep.Api;
|
|
loopAttempted=true; loopSettled=new TaskCompletionSource<bool>(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<bool>("NewlyCreated",false),PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
|
|
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<IStageService>().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<bool>(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<bool> 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
|