Files
ft/Client/Assets/Scripts/Services/PlayerData/PlayerSaveStores.cs
Liubing\LB 6813da149a feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。
保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。

验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。
包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
2026-09-30 19:19:05 +08:00

200 lines
11 KiB
C#

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