feat(upserver): 保存M2客户端KV迁移至本人统计写检查点

保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
Liubing\LB
2026-09-30 19:19:05 +08:00
parent 1c0cdf7a99
commit 6813da149a
511 changed files with 151858 additions and 2376 deletions

View File

@@ -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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 12a766df765ed0d4981d8da38eead5e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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();
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8cda3d2b981ada043af2162ca707d8bf
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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; }
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7a9c9b2dab8e5a4ca300f0bcf6637e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -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)

View File

@@ -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

View File

@@ -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;

View File

@@ -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);
}
}
}

View File

@@ -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;

View File

@@ -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;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4d8ad9b0b9d34ce385e2b31813d24ff3

View File

@@ -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();

View 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));
}
}
}

View File

@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b48427bc908093046aaca2238303cf40
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: