feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal interface IKVUserLifecycle
|
||||
{
|
||||
bool KVHandoffReady { get; }
|
||||
PlayerKVGameLoadError KVLastError { get; }
|
||||
Task CloseKVForValidationAsync();
|
||||
Task<PlayerKVShutdownStatus> ObserveKVQuitAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 12a766df765ed0d4981d8da38eead5e3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal interface IKVUserLoadReceiver
|
||||
{
|
||||
void ResetForKVLoad();
|
||||
void ReplaceFromKVDecoded(PlayerKVLoginState state, long epoch, IReadOnlyDictionary<string, string> raw);
|
||||
void DiscardKVIntents();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8cda3d2b981ada043af2162ca707d8bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,62 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// A store belongs to one login. Callers retain this instance across awaits.
|
||||
public interface IPlayerSaveStore
|
||||
{
|
||||
bool IsCurrent { get; }
|
||||
PlayerSaveReadResult Read(string key);
|
||||
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
|
||||
Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default);
|
||||
Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default);
|
||||
Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default);
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveReadResult
|
||||
{
|
||||
public bool Success => Error == null;
|
||||
public bool? Found { get; }
|
||||
public string Value { get; }
|
||||
public string Error { get; }
|
||||
internal PlayerSaveReadResult(bool found, string value) { Found = found; Value = value; }
|
||||
internal PlayerSaveReadResult(string error) { Error = error ?? "ReadFailed"; }
|
||||
public string ValueOrThrow()
|
||||
{
|
||||
if (!Success) throw new InvalidOperationException("Player save read failed: " + Error);
|
||||
return Value;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveReceipt
|
||||
{
|
||||
internal readonly IPlayerSaveStore Store;
|
||||
internal readonly object Ticket;
|
||||
internal PlayerSaveReceipt(IPlayerSaveStore store, object ticket) { Store = store; Ticket = ticket; }
|
||||
}
|
||||
|
||||
public sealed class PlayerSaveWriteResult
|
||||
{
|
||||
public bool Accepted => Error == null;
|
||||
public string Error { get; }
|
||||
public PlayerSaveReceipt Receipt { get; }
|
||||
internal PlayerSaveWriteResult(PlayerSaveReceipt receipt) { Receipt = receipt; }
|
||||
internal PlayerSaveWriteResult(string error) { Error = error ?? "WriteRejected"; }
|
||||
public void RequireAccepted()
|
||||
{
|
||||
if (!Accepted) throw new InvalidOperationException("Player save write rejected: " + Error);
|
||||
}
|
||||
}
|
||||
|
||||
// Buffered and LocalOnly are deliberately different from cloud confirmation.
|
||||
public enum PlayerSaveStatus { Confirmed, Buffered, LocalOnly, Rejected, Superseded, Unknown, Cancelled }
|
||||
public sealed class PlayerSaveConfirmation
|
||||
{
|
||||
public PlayerSaveStatus Status { get; }
|
||||
public bool Confirmed => Status == PlayerSaveStatus.Confirmed;
|
||||
public string Error { get; }
|
||||
internal PlayerSaveConfirmation(PlayerSaveStatus status, string error = null) { Status = status; Error = error; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7a9c9b2dab8e5a4ca300f0bcf6637e8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -17,6 +17,7 @@ namespace game.PlayerDataStorage
|
||||
internal string CacheRoot { get; }
|
||||
internal int LoginObserveMs { get; }
|
||||
internal int QuitObserveMs { get; }
|
||||
internal bool RequireExistingArchive { get; }
|
||||
internal Func<int,CancellationToken,Task> Delay { get; }
|
||||
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
|
||||
readonly PlayerKVControlledLoadCapability auth,kv,lease;
|
||||
@@ -28,7 +29,8 @@ namespace game.PlayerDataStorage
|
||||
bool claimed;
|
||||
private PlayerKVGameLoadCapability(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,Uri api,string cacheRoot,
|
||||
int loginMs,int quitMs,Func<int,CancellationToken,Task> delay,
|
||||
PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease)
|
||||
PlayerKVControlledLoadCapability auth,PlayerKVControlledLoadCapability kv,PlayerKVControlledLoadCapability lease,
|
||||
bool requireExistingArchive=false)
|
||||
{
|
||||
Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot));
|
||||
if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) ||
|
||||
@@ -36,6 +38,19 @@ namespace game.PlayerDataStorage
|
||||
loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000) throw new ArgumentException("Controlled load configuration invalid.");
|
||||
Api=new Uri(api.AbsoluteUri.TrimEnd('/')+"/"); CacheRoot=cacheRoot;
|
||||
LoginObserveMs=loginMs; QuitObserveMs=quitMs; Delay=delay ?? Task.Delay; this.auth=auth; this.kv=kv; this.lease=lease;
|
||||
RequireExistingArchive=requireExistingArchive;
|
||||
}
|
||||
internal static PlayerKVGameLoadCapability CreateForGame(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
|
||||
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
|
||||
Func<CookieContainer,HttpMessageHandler> lease)
|
||||
{
|
||||
if(api==null || api.AbsoluteUri!="http://s101.local:18080/")
|
||||
throw new ArgumentException("Formal KV endpoint mismatch.",nameof(api));
|
||||
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,10000,5000,null,
|
||||
PlayerKVControlledLoadCapability.CreateForGame(auth),PlayerKVControlledLoadCapability.CreateForGame(kv),
|
||||
PlayerKVControlledLoadCapability.CreateForGame(lease),true);
|
||||
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
|
||||
return capability;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
|
||||
@@ -65,7 +80,7 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
// Validate all three returned handler graphs before the first authentication send.
|
||||
authentication=auth.CreateHandler(jar); pendingKV=kv.CreateHandler(jar); pendingLease=lease.CreateHandler(jar);
|
||||
if(ReferenceEquals(authentication,pendingKV) || ReferenceEquals(authentication,pendingLease) || ReferenceEquals(pendingKV,pendingLease))
|
||||
if(ShareHandlerGraph(authentication,pendingKV,pendingLease))
|
||||
throw new InvalidOperationException("Distinct transport ownership required.");
|
||||
return authentication;
|
||||
}
|
||||
@@ -77,6 +92,17 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
}
|
||||
}
|
||||
static bool ShareHandlerGraph(params HttpMessageHandler[] roots)
|
||||
{
|
||||
var seen=new System.Collections.Generic.List<HttpMessageHandler>();
|
||||
foreach(var root in roots)
|
||||
for(var node=root;node!=null;node=(node as DelegatingHandler)?.InnerHandler)
|
||||
{
|
||||
if(seen.Exists(previous=>ReferenceEquals(previous,node))) return true;
|
||||
seen.Add(node);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
HttpMessageHandler TakeKV(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
|
||||
@@ -237,7 +237,7 @@ namespace game.PlayerDataStorage
|
||||
var player=new GameCore.PlayerData(tables,null); var small=new GameCore.SmallGameData(tables,null,null); var fish=new GameCore.PlayerFishData(tables,null);
|
||||
var shop=new GameCore.PlayerShopData(); var eventBase=new GameCore.FishingEventData(tables); var buffs=new BuffDataCenter(tables); var camp=new CampDataMM(tables,null);
|
||||
var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var guide=new GameCore.GuideDataCenter(null,null); var battle=new BattlePassData(tables);
|
||||
var sign=new GameCore.SigninData(tables); var trigger=new GameCore.TriggerPackData(tables); var club=new ClubService(); var challenge=new FishingChallengeManager();
|
||||
var sign=new GameCore.SigninData(tables); var trigger=new GameCore.TriggerPackData(tables); var club=new ClubService(false); var challenge=new FishingChallengeManager();
|
||||
var chains=new DataCenter.OfferChainsChestManager(); var disco=new DataCenter.OfferDiscoPartyManager(); var partner=new DataCenter.EventPartnerGatherManager(); var cube=new EventCubeMeltManager();
|
||||
var eventPack=new EventPackData(); var piggy=new PiggyBankPackData(); var bargain=new BargainPackData(); var foot=new FootPrintService(); var shooting=new EventShootingRangeData();
|
||||
var potion=new EventPotionDataManager(); var pinball=new EventPinballDataManager(); var scratch=new EventScratchTicketDataManager();
|
||||
@@ -924,7 +924,7 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
int assertions = 0;
|
||||
void Check(bool condition) { assertions++; if (!condition) throw new InvalidOperationException("Club assertion failed."); }
|
||||
var club = new ClubService();
|
||||
var club = new ClubService(false);
|
||||
var snapshot = new PlayerKVSchemaSnapshot("M14",Array.Empty<int>(),Array.Empty<int>(),new Dictionary<string,int>(),
|
||||
CultureInfo.InvariantCulture,new DateTime(2026,9,23,0,0,0,DateTimeKind.Utc),0);
|
||||
try
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVGameLoadError
|
||||
{ None, BackendNotEnabled, BackendUnsupported, Input, Prepare, Schema, Apply, Stale, SideEffect, GateBusy, PreviousClosePending, PreviousCloseFailed, ClosePending, CloseFailed }
|
||||
internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation }
|
||||
internal enum PlayerKVGameLoadState { Rejected, LoadedForValidation, GameLoaded }
|
||||
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
|
||||
internal sealed class PlayerKVPrepareEvidence
|
||||
{
|
||||
@@ -27,18 +27,21 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadResult
|
||||
{
|
||||
internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected;
|
||||
internal PlayerKVGameLoadState State { get; }
|
||||
internal PlayerKVGameLoadError Error { get; }
|
||||
internal PlayerKVGameLoadError OriginalError { get; }
|
||||
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
|
||||
internal PlayerKVSchemaVerdict? SchemaVerdict { get; }
|
||||
internal PlayerKVPrepareEvidence Prepare { get; }
|
||||
internal long Epoch { get; }
|
||||
internal PlayerKVAccountDescriptor Account { get; }
|
||||
internal int ModuleCount { get; }
|
||||
internal int IntentCount { get; }
|
||||
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null,
|
||||
PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0)
|
||||
{ Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents; }
|
||||
PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0,bool forGame=false,PlayerKVAccountDescriptor account=null)
|
||||
{ Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents;
|
||||
Account=error==PlayerKVGameLoadError.None && forGame ? account : null;
|
||||
State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadCoordinator
|
||||
{
|
||||
@@ -61,6 +64,7 @@ namespace game.PlayerDataStorage
|
||||
Action clearAfterApply;
|
||||
long epoch;
|
||||
bool loaded;
|
||||
PlayerKVGameSessionHandle gameHandle;
|
||||
internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0;
|
||||
internal int QuitObserveMs => capability?.QuitObserveMs ?? 5000;
|
||||
internal PlayerKVGameLoadCoordinator(PlayerKVGameLoadTransaction transaction,Func<bool> unstarted,Action notify,Func<int> intentCount,
|
||||
@@ -72,7 +76,29 @@ namespace game.PlayerDataStorage
|
||||
this.unbind=unbind ?? throw new ArgumentNullException(nameof(unbind)); this.registry=registry ?? PlayerKVShutdownRegistry.Process;
|
||||
mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
}
|
||||
internal async Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
internal Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
=> LoadCoreAsync(id,next,token,identityCurrent,false);
|
||||
internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
|
||||
=> LoadCoreAsync(id,next,token,identityCurrent,true);
|
||||
internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle)
|
||||
{
|
||||
handle=null;
|
||||
PlayerKVLoginContext owner; PlayerKVGameSessionHandle published;
|
||||
lock(closeGate)
|
||||
{
|
||||
if(closing!=null || epoch!=expectedEpoch || gameHandle==null) return false;
|
||||
owner=context; published=gameHandle;
|
||||
}
|
||||
// Never call the external identity predicate while holding a publication monitor.
|
||||
if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false;
|
||||
lock(closeGate)
|
||||
{
|
||||
if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false;
|
||||
handle=published;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token,Func<bool> identityCurrent,bool forGame)
|
||||
{
|
||||
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
|
||||
return Result(PlayerKVGameLoadError.Input);
|
||||
@@ -89,9 +115,13 @@ namespace game.PlayerDataStorage
|
||||
if(token.IsCancellationRequested || !unstarted() || transaction.Poisoned) return Result(PlayerKVGameLoadError.Input);
|
||||
if(!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard)) return Result(PlayerKVGameLoadError.GateBusy);
|
||||
lifetimeRegistration.Dispose();
|
||||
guard=enteredGuard; capability=next; closing=null; loaded=false;
|
||||
attempt=Interlocked.Increment(ref epoch);
|
||||
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
|
||||
lock(closeGate)
|
||||
{
|
||||
guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null;
|
||||
attempt=Interlocked.Increment(ref epoch);
|
||||
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
|
||||
}
|
||||
var owner=context;
|
||||
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); });
|
||||
var work=context.PrepareAsync(token);
|
||||
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
@@ -103,6 +133,8 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
if(!prepared.Prepared) return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare,attempt,next,prepared,verdict);
|
||||
if(!context.IsCurrent || epoch!=attempt) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
if(next.RequireExistingArchive && (context.AccountDescriptor?.NewlyCreated!=false || prepared.Data==null || prepared.Data.Count==0))
|
||||
return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
|
||||
var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity);
|
||||
verdict=decoded.Verdict;
|
||||
if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
|
||||
@@ -113,9 +145,26 @@ namespace game.PlayerDataStorage
|
||||
finally { var pending=clearAfterApply; clearAfterApply=null; pending?.Invoke(); }
|
||||
if(applied!=PlayerKVApplyError.None) return await Reject(applied==PlayerKVApplyError.Stale ? PlayerKVGameLoadError.Stale :
|
||||
applied==PlayerKVApplyError.SideEffect ? PlayerKVGameLoadError.SideEffect : PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict);
|
||||
if(token.IsCancellationRequested || !context.IsCurrent || guard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
loaded=true;
|
||||
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intentCount());
|
||||
if(token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
var intents=intentCount();
|
||||
PlayerKVGameSessionHandle original=null;
|
||||
if(forGame && !owner.TryAcquireGameHandle(attempt,out original)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
bool published=false;
|
||||
lock(closeGate)
|
||||
{
|
||||
if(closing==null && epoch==attempt && !token.IsCancellationRequested && ReferenceEquals(context,owner) && enteredGuard.IsCurrent && enteredGuard.Violations==0)
|
||||
{
|
||||
if(forGame)
|
||||
{
|
||||
enteredGuard.Dispose();
|
||||
// Dispose serializes with Reject; its final count cannot change afterwards.
|
||||
if(enteredGuard.Violations==0) { gameHandle=original; published=true; }
|
||||
}
|
||||
else { loaded=true; published=true; }
|
||||
}
|
||||
}
|
||||
if(!published) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intents,forGame:forGame,account:context.AccountDescriptor);
|
||||
}
|
||||
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); }
|
||||
finally { loginGate.Release(); }
|
||||
@@ -148,7 +197,7 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask;
|
||||
if(closing!=null) return closing;
|
||||
loaded=false; Interlocked.Increment(ref epoch);
|
||||
loaded=false; gameHandle=null; Interlocked.Increment(ref epoch);
|
||||
if(context==null && guard==null) return Task.CompletedTask;
|
||||
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
closing=completion.Task;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
#if !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
@@ -12,10 +12,10 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
static T Require<T>() where T:class => GContext.container.Resolve<T>() ??
|
||||
throw new InvalidOperationException("KV game module is not registered: "+typeof(T).FullName);
|
||||
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc)
|
||||
internal static PlayerKVSchemaSnapshot Snapshot(DateTime nowUtc,string version="kv-loop")
|
||||
{
|
||||
var tables=Require<cfg.Tables>();
|
||||
return new PlayerKVSchemaSnapshot("kv-loop",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
|
||||
return new PlayerKVSchemaSnapshot(version,tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
|
||||
Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture,
|
||||
nowUtc,tables.TbGlobalConfig.InitWheelTicket,
|
||||
tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType),
|
||||
@@ -23,7 +23,7 @@ namespace game.PlayerDataStorage
|
||||
tables.TbFishData.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishConfig(p.Key,p.Value.AreaID,p.Value.Quality,p.Value.FishCardID,p.Value.BaseWeight,p.Value.PointExtraParam,p.Value.WeightRandom)),
|
||||
tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList)));
|
||||
}
|
||||
internal static PlayerKVGameLoadTransaction Bind(PlayFabUserService user)
|
||||
internal static PlayerKVGameLoadTransaction Bind(IKVUserLoadReceiver user)
|
||||
{
|
||||
var player=Require<GameCore.PlayerData>(); var small=Require<GameCore.SmallGameData>(); var fish=Require<GameCore.PlayerFishData>();
|
||||
var shop=Require<GameCore.PlayerShopData>(); var eventBase=Require<GameCore.FishingEventData>(); var buffs=Require<BuffDataCenter>(); var camp=Require<CampDataMM>();
|
||||
@@ -53,7 +53,7 @@ namespace game.PlayerDataStorage
|
||||
Add<PlayerKVProgressPackPair>(28,()=>{progress.ResetForKVLoad();migration.ResetForKVLoad();},p=>{progress.ReplaceFromKVDecoded(p.Current);migration.ReplaceFromKVDecoded(p.Migration);});
|
||||
Add<PlayerKVPinataState>(29,pinata.ResetForKVLoad,pinata.ReplaceFromKVDecoded); Add<PlayerKVEmptyModuleState>(30,digging.ResetForKVLoad,digging.ReplaceFromKVDecoded);
|
||||
modules.Add(new PlayerKVLoadModule(31,user.ResetForKVLoad,d=>user.ReplaceFromKVDecoded(d.Module<PlayerKVLoginState>(31),d.Epoch,d.Raw)));
|
||||
return new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,()=>user.KVIntents?.Discard());
|
||||
return new PlayerKVGameLoadTransaction(modules,System.Threading.Thread.CurrentThread.ManagedThreadId,user.DiscardKVIntents);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,21 @@ namespace game.PlayerDataStorage
|
||||
internal PlayerKVAccountDescriptor AccountDescriptor =>
|
||||
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
|
||||
|
||||
// Only the coordinator may publish this original handle after Apply and Guard release.
|
||||
internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
|
||||
{
|
||||
gameHandle = null;
|
||||
// IsCurrent can invoke external code and synchronously reenter CloseAsync.
|
||||
if (expectedEpoch != epoch || !IsCurrent) return false;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || revoked != 0 || prepared != 1 || handle == null || handle.Epoch != expectedEpoch)
|
||||
return false;
|
||||
gameHandle = handle;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// All issuance and Close admission share gate. The editor factory may not block on Close.
|
||||
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
|
||||
out PlayerKVAccountLease lease)
|
||||
@@ -317,21 +332,26 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Construction is absent from player builds; no default network handler is supplied.</summary>
|
||||
/// <summary>Controlled transport factory; formal and Editor validation modes remain separate.</summary>
|
||||
internal sealed class PlayerKVControlledLoadCapability
|
||||
{
|
||||
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); }
|
||||
readonly bool formal;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler, bool formal)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); this.formal = formal; }
|
||||
internal bool IsFormal => formal;
|
||||
|
||||
internal static PlayerKVControlledLoadCapability CreateForGame(
|
||||
Func<CookieContainer, HttpMessageHandler> handler) => new PlayerKVControlledLoadCapability(handler, true);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVControlledLoadCapability CreateForValidation(
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler);
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler, false);
|
||||
#endif
|
||||
|
||||
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
|
||||
{
|
||||
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic handler required.");
|
||||
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Transport handler required.");
|
||||
var seen = new HashSet<HttpMessageHandler>();
|
||||
var node = handler;
|
||||
while (node != null)
|
||||
@@ -340,12 +360,23 @@ namespace game.PlayerDataStorage
|
||||
// It never becomes a lease; the rejecting factory retains responsibility for that graph.
|
||||
if (!seen.Add(node))
|
||||
throw new InvalidOperationException("Cyclic transport is not a validation handler.");
|
||||
if (node is HttpClientHandler ||
|
||||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
|
||||
if (!formal && (node is HttpClientHandler ||
|
||||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler"))
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Real or cyclic transport is not a validation handler.");
|
||||
}
|
||||
if (formal && node is HttpClientHandler real &&
|
||||
(!real.UseCookies || !ReferenceEquals(real.CookieContainer, cookies) || real.AllowAutoRedirect))
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Formal transport must share the session cookies and reject redirects.");
|
||||
}
|
||||
if (formal && node.GetType().FullName == "System.Net.Http.SocketsHttpHandler")
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Unverified formal transport handler.");
|
||||
}
|
||||
node = (node as DelegatingHandler)?.InnerHandler;
|
||||
}
|
||||
return handler;
|
||||
@@ -366,6 +397,8 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly HttpClient client;
|
||||
readonly CookieContainer cookies;
|
||||
readonly bool formal;
|
||||
readonly Func<bool> current;
|
||||
readonly Func<bool> admission;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
@@ -374,6 +407,8 @@ namespace game.PlayerDataStorage
|
||||
int active;
|
||||
internal Uri ApiBaseUri { get; }
|
||||
internal long Epoch { get; }
|
||||
internal bool IsFormal => formal;
|
||||
internal CookieContainer FormalCookies => formal && IsCurrent ? cookies : null;
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get
|
||||
@@ -387,18 +422,24 @@ namespace game.PlayerDataStorage
|
||||
PlayerKVControlledLoadCapability capability, Func<bool> current, Func<bool> admission)
|
||||
{
|
||||
if (capability == null) throw new ArgumentNullException(nameof(capability));
|
||||
ApiBaseUri = api; Epoch = epoch; this.current = current; this.admission = admission;
|
||||
ApiBaseUri = api; Epoch = epoch; this.cookies = cookies;
|
||||
formal = capability.IsFormal; this.current = current; this.admission = admission;
|
||||
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
|
||||
if (formal) client.Timeout = TimeSpan.FromSeconds(15);
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVAccountResponse> SendAsync(HttpMethod method, string route,
|
||||
string body = null, string contentType = "application/json", CancellationToken token = default)
|
||||
{
|
||||
// Exact canonical relative paths: no query, fragment, encoding, traversal or absolute URL.
|
||||
if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
|
||||
if (formal)
|
||||
{
|
||||
if (!IsCanonicalBusinessRoute(route))
|
||||
throw new ArgumentException("Only canonical relative business routes are allowed.", nameof(route));
|
||||
}
|
||||
else if (route != "__kv_validation/ok" && route != "__kv_validation/unauthorized")
|
||||
throw new ArgumentException("Only canonical validation routes are allowed.", nameof(route));
|
||||
if (method != HttpMethod.Get && method != HttpMethod.Post)
|
||||
throw new ArgumentException("Unsupported validation method.", nameof(method));
|
||||
throw new ArgumentException("Unsupported lease method.", nameof(method));
|
||||
if (!IsCurrent) return new PlayerKVAccountResponse(false);
|
||||
lock (gate)
|
||||
{
|
||||
@@ -436,6 +477,27 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsCanonicalBusinessRoute(string route)
|
||||
{
|
||||
if (string.IsNullOrEmpty(route) || route[0] == '/' || route[0] == '\\' ||
|
||||
Uri.TryCreate(route, UriKind.Absolute, out _) ||
|
||||
route.IndexOf("//", StringComparison.Ordinal) >= 0 ||
|
||||
route.IndexOfAny(new[] { '?', '#', '%', '\\' }) >= 0)
|
||||
return false;
|
||||
var segments = route.Split('/');
|
||||
foreach (var segment in segments)
|
||||
{
|
||||
if (segment.Length == 0 || segment == "." || segment == "..") return false;
|
||||
foreach (var character in segment)
|
||||
if (char.IsControl(character)) return false;
|
||||
}
|
||||
return !route.Equals("__kv_validation", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.StartsWith("__kv_validation/", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.Equals("account/Login", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.Equals("playerdata", StringComparison.OrdinalIgnoreCase) &&
|
||||
!route.StartsWith("playerdata/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
internal Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable process-start selection. Accounts 101 and 102 are the reviewed first cohort;
|
||||
/// expanding it requires a new complete migration design review.
|
||||
/// </summary>
|
||||
internal sealed class PlayerKVRuntimeSelection
|
||||
{
|
||||
internal const string FormalApiUrl = "http://s101.local:18080/";
|
||||
|
||||
internal bool IsKV { get; }
|
||||
internal Uri Api { get; }
|
||||
|
||||
private PlayerKVRuntimeSelection(bool isKV, Uri api)
|
||||
{
|
||||
IsKV = isKV;
|
||||
Api = api;
|
||||
}
|
||||
|
||||
internal static PlayerKVRuntimeSelection Capture(string backend, string api, bool agg)
|
||||
{
|
||||
if (backend == null || backend == "playfab")
|
||||
return new PlayerKVRuntimeSelection(false, null);
|
||||
if (backend != "kv")
|
||||
throw new ArgumentException("Unsupported save backend.", nameof(backend));
|
||||
if (agg)
|
||||
throw new InvalidOperationException("KV login is not enabled for AGG channels.");
|
||||
if (!string.Equals(api, FormalApiUrl, StringComparison.Ordinal))
|
||||
throw new ArgumentException("KV API address does not match the reviewed endpoint.", nameof(api));
|
||||
return new PlayerKVRuntimeSelection(true, new Uri(FormalApiUrl));
|
||||
}
|
||||
|
||||
internal bool TryAdmitPlayer(string requestedId, out string canonicalId)
|
||||
{
|
||||
canonicalId = null;
|
||||
if (!IsKV || !long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) ||
|
||||
(id != 101 && id != 102)) return false;
|
||||
canonicalId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d8ad9b0b9d34ce385e2b31813d24ff3
|
||||
@@ -88,27 +88,27 @@ namespace game.PlayerDataStorage
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out guard)); guard.Dispose(); guard = null;
|
||||
Directory.CreateDirectory(cache); bridge = new PlayerKVGameSessionBridge(KV);
|
||||
var a = await Open("101");
|
||||
var present = PlayFabMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A");
|
||||
Check(PlayFabMgr.ReadKV(a, "missing").Found == false);
|
||||
Check(PlayFabMgr.ReadKV(a, "empty").Found == true && PlayFabMgr.ReadKV(a, "empty").Value == "");
|
||||
Check(PlayFabMgr.ReadKV(a, null).Found == null);
|
||||
var edit = PlayFabMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0);
|
||||
var local = PlayFabMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null);
|
||||
Check(PlayFabMgr.ReadKV(a, "k").Value == "local");
|
||||
var flush = await PlayFabMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote");
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed);
|
||||
var present = KVMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A");
|
||||
Check(KVMgr.ReadKV(a, "missing").Found == false);
|
||||
Check(KVMgr.ReadKV(a, "empty").Found == true && KVMgr.ReadKV(a, "empty").Value == "");
|
||||
Check(KVMgr.ReadKV(a, null).Found == null);
|
||||
var edit = KVMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0);
|
||||
var local = KVMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null);
|
||||
Check(KVMgr.ReadKV(a, "k").Value == "local");
|
||||
var flush = await KVMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote");
|
||||
Check((await KVMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed);
|
||||
await bridge.CloseAsync(); epoch++;
|
||||
var b = await Open("102"); var writesBefore = writes;
|
||||
Check(PlayFabMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing);
|
||||
Check(!PlayFabMgr.WriteKV(a, "k", "late").Accepted);
|
||||
Check((await PlayFabMgr.FlushKVAsync(a)).Blocked == 1);
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked);
|
||||
Check(PlayFabMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore);
|
||||
Check(KVMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing);
|
||||
Check(!KVMgr.WriteKV(a, "k", "late").Accepted);
|
||||
Check((await KVMgr.FlushKVAsync(a)).Blocked == 1);
|
||||
Check((await KVMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked);
|
||||
Check(KVMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore);
|
||||
Check(PlayerKVGameLoadGuard.TryEnter(out guard));
|
||||
Check(PlayFabMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive);
|
||||
Check(PlayFabMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked);
|
||||
Check((await PlayFabMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled);
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled);
|
||||
Check(KVMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive);
|
||||
Check(KVMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked);
|
||||
Check((await KVMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled);
|
||||
Check((await KVMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled);
|
||||
Check(guard.Violations == 4 && guard.Count(PlayerKVGuardPoint.ReadLocal) == 1 && guard.Count(PlayerKVGuardPoint.Update) == 1 && guard.Count(PlayerKVGuardPoint.Sync) == 2);
|
||||
Check(auth == 2 && gets == 2 && writes == 1);
|
||||
await Close();
|
||||
|
||||
199
Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs
Normal file
199
Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs
Normal file
@@ -0,0 +1,199 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable
|
||||
{
|
||||
PlayerKVGameSessionHandle handle;
|
||||
CancellationToken lifetime;
|
||||
bool bound, disposed;
|
||||
readonly object receiptsGate = new object();
|
||||
readonly Dictionary<string, PlayerKVSyncTicket> pendingReceipts = new Dictionary<string, PlayerKVSyncTicket>(StringComparer.Ordinal);
|
||||
public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed;
|
||||
|
||||
internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime)
|
||||
{
|
||||
if (bound || disposed || original == null || loginLifetime.IsCancellationRequested)
|
||||
throw new InvalidOperationException("Player save store cannot be rebound.");
|
||||
// This also checks the load guard before permitting business use.
|
||||
if (!KVMgr.ReadKV(original, "LastLoginTimeUtc").Success)
|
||||
throw new InvalidOperationException("Player save handle is not ready.");
|
||||
handle = original;
|
||||
lifetime = loginLifetime;
|
||||
bound = true;
|
||||
}
|
||||
|
||||
public PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
|
||||
var result = KVMgr.ReadKV(handle, key);
|
||||
return result.Success ? new PlayerSaveReadResult(result.Found.Value, result.Value) :
|
||||
new PlayerSaveReadResult(result.Error.ToString());
|
||||
}
|
||||
|
||||
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
lock (receiptsGate)
|
||||
{
|
||||
if (!IsCurrent)
|
||||
{
|
||||
var rejected = new PlayerSaveWriteResult("StaleOrNotReady");
|
||||
return rejected;
|
||||
}
|
||||
var result = KVMgr.WriteKV(handle, key, value, remoteStore);
|
||||
if (result.Accepted && result.Ticket != null) pendingReceipts[key] = result.Ticket;
|
||||
var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) :
|
||||
new PlayerSaveWriteResult(result.StateResult.Error.ToString());
|
||||
return write;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default)
|
||||
{
|
||||
if (token.IsCancellationRequested) return Cancelled();
|
||||
var write = Write(key, value);
|
||||
if (!write.Accepted) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, write.Error);
|
||||
return await ConfirmAsync(write.Receipt, token);
|
||||
}
|
||||
|
||||
public async Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default)
|
||||
{
|
||||
if (!IsCurrent || token.IsCancellationRequested) return Cancelled();
|
||||
if (receipt == null || !ReferenceEquals(receipt.Store, this))
|
||||
return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, "ForeignReceipt");
|
||||
if (receipt.Ticket == null) return new PlayerSaveConfirmation(PlayerSaveStatus.LocalOnly);
|
||||
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
|
||||
{
|
||||
try
|
||||
{
|
||||
await KVMgr.FlushKVAsync(handle, wait.Token);
|
||||
var ticket = await KVMgr.WaitForKVTicketAsync(handle, (PlayerKVSyncTicket)receipt.Ticket, wait.Token);
|
||||
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
|
||||
if (ticket.Status == PlayerKVTicketStatus.Confirmed)
|
||||
{
|
||||
ForgetConfirmed((PlayerKVSyncTicket)receipt.Ticket);
|
||||
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
|
||||
}
|
||||
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
|
||||
ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
|
||||
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
|
||||
ticket.TransportError.ToString());
|
||||
}
|
||||
catch (OperationCanceledException) { return Cancelled(); }
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default)
|
||||
{
|
||||
if (!IsCurrent || token.IsCancellationRequested) return Cancelled();
|
||||
PlayerKVSyncTicket[] captured;
|
||||
lock (receiptsGate) captured = pendingReceipts.Values.ToArray();
|
||||
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await KVMgr.FlushKVAsync(handle, wait.Token);
|
||||
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
|
||||
var status = result.Unknown > 0 || result.CacheStatus == PlayerKVSyncStatus.UnknownOutcome ?
|
||||
PlayerSaveStatus.Unknown : result.Blocked > 0 || result.TransportError != PlayerKVError.None ||
|
||||
result.CacheStatus != PlayerKVSyncStatus.Ready ? PlayerSaveStatus.Rejected : PlayerSaveStatus.Confirmed;
|
||||
if (status != PlayerSaveStatus.Confirmed) return new PlayerSaveConfirmation(status, result.TransportError.ToString());
|
||||
// Multiple writes before this flush are covered by the captured final receipt.
|
||||
// A replacement during the await belongs to a later flush and cannot confirm it.
|
||||
foreach (var receipt in captured)
|
||||
{
|
||||
var ticket = await KVMgr.WaitForKVTicketAsync(handle, receipt, wait.Token);
|
||||
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
|
||||
if (ticket.Status != PlayerKVTicketStatus.Confirmed)
|
||||
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
|
||||
ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
|
||||
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
|
||||
ticket.TransportError.ToString());
|
||||
ForgetConfirmed(receipt);
|
||||
}
|
||||
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
|
||||
}
|
||||
catch (OperationCanceledException) { return Cancelled(); }
|
||||
}
|
||||
}
|
||||
|
||||
static PlayerSaveConfirmation Cancelled() => new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
|
||||
void ForgetConfirmed(PlayerKVSyncTicket receipt)
|
||||
{
|
||||
lock (receiptsGate)
|
||||
if (pendingReceipts.TryGetValue(receipt.Key, out var latest) && ReferenceEquals(latest, receipt))
|
||||
pendingReceipts.Remove(receipt.Key);
|
||||
}
|
||||
public void Dispose() { disposed = true; lock (receiptsGate) { pendingReceipts.Clear(); } }
|
||||
}
|
||||
|
||||
public sealed class PlayFabPlayerSaveStore : IPlayerSaveStore
|
||||
{
|
||||
readonly PlayFabMgr manager;
|
||||
readonly CancellationToken lifetime;
|
||||
public PlayFabPlayerSaveStore(IUserService user) : this(PlayFabMgr.Instance, user.LoginLifetime) { }
|
||||
internal PlayFabPlayerSaveStore(PlayFabMgr manager, CancellationToken lifetime)
|
||||
{ this.manager = manager; this.lifetime = lifetime; }
|
||||
public bool IsCurrent => manager != null && !lifetime.IsCancellationRequested;
|
||||
|
||||
public PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
|
||||
try
|
||||
{
|
||||
var value = manager.GetLocalData(key);
|
||||
return new PlayerSaveReadResult(value != null, value);
|
||||
}
|
||||
catch (Exception ex) { return new PlayerSaveReadResult(ex.GetType().Name); }
|
||||
}
|
||||
|
||||
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
if (!IsCurrent) return new PlayerSaveWriteResult("StaleOrNotReady");
|
||||
try
|
||||
{
|
||||
manager.UpdateUserDataValue(key, value, remoteStore);
|
||||
return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, remoteStore ? (object)true : null));
|
||||
}
|
||||
catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); }
|
||||
}
|
||||
|
||||
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default)
|
||||
{
|
||||
if (!IsCurrent || token.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
|
||||
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
|
||||
{
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (wait.Token.Register(() => cancelled.TrySetResult(false)))
|
||||
{
|
||||
try
|
||||
{
|
||||
var save = manager.SyncDataImmidiate(key, value);
|
||||
var finished = await Task.WhenAny(save, cancelled.Task);
|
||||
if (!IsCurrent || wait.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
|
||||
return new PlayerSaveConfirmation(await finished ? PlayerSaveStatus.Confirmed : PlayerSaveStatus.Rejected);
|
||||
}
|
||||
catch (Exception ex) { return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, ex.GetType().Name); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default) =>
|
||||
Task.FromResult(new PlayerSaveConfirmation(!IsCurrent || token.IsCancellationRequested ? PlayerSaveStatus.Cancelled :
|
||||
receipt == null || !ReferenceEquals(receipt.Store, this) ? PlayerSaveStatus.Rejected :
|
||||
receipt.Ticket == null ? PlayerSaveStatus.LocalOnly : PlayerSaveStatus.Buffered));
|
||||
|
||||
public Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default)
|
||||
{
|
||||
if (!IsCurrent || token.IsCancellationRequested)
|
||||
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled));
|
||||
// The original manager owns its timer and callback-based buffered dispatch.
|
||||
manager.FlushBufferedData();
|
||||
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Buffered));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b48427bc908093046aaca2238303cf40
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user