Files
ft/CodexOutput/upserver/B3BatchMigration/M2/C2d修改前快照/065-PlayerSaveStores.cs.txt
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

273 lines
15 KiB
Plaintext

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace game.PlayerDataStorage
{
// Collection is limited to a synchronous gameplay fragment. It never flows across await.
public sealed class PlayerSaveAction
{
[ThreadStatic] static PlayerSaveAction current;
readonly KVPlayerSaveStore store;
readonly Dictionary<string, PlayerSaveReceipt> receipts = new Dictionary<string, PlayerSaveReceipt>(StringComparer.Ordinal);
readonly List<Action> presentations = new List<Action>();
string failure;
PlayerSaveAction(KVPlayerSaveStore store) { this.store = store; }
internal static bool Accepts(KVPlayerSaveStore store)
{
if (current == null || ReferenceEquals(current.store, store)) return true;
current.failure = "ForeignStoreInReward";
return false;
}
internal static void Record(KVPlayerSaveStore store, string key, PlayerSaveWriteResult result)
{
if (current == null || !ReferenceEquals(current.store, store)) return;
if (!result.Accepted) current.failure = result.Error;
else current.receipts[key] = result.Receipt;
}
public static void Present(Action presentation)
{
if (current == null) presentation();
else current.presentations.Add(presentation);
}
public static async Task RunAsync(IPlayerSaveStore store, string actionKey, Action apply,
Action savePlayer, CancellationToken token = default)
{
if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException();
if (!(store is KVPlayerSaveStore kv)) { apply(); return; }
// A live login may still have an UnknownOutcome/Guarded owner. Check readiness before inventory mutation.
kv.Read("NewPlayerDataDic").ValueOrThrow();
if (current != null) throw new InvalidOperationException("Reward action must be awaited by its caller.");
if (!kv.BeginAction(actionKey)) throw new InvalidOperationException("Reward is pending or requires recovery.");
var action = new PlayerSaveAction(kv);
try
{
current = action;
apply();
savePlayer();
}
finally { current = null; }
// Keep the action reserved after any failure: replaying rewards is not recovery.
if (action.failure != null) throw new InvalidOperationException("Reward write rejected: " + action.failure);
if (action.receipts.Count == 0) throw new InvalidOperationException("Reward produced no save receipts.");
foreach (var receipt in action.receipts.Values)
{
var result = await store.ConfirmAsync(receipt, token);
if (!result.Confirmed) throw new InvalidOperationException("Reward save not confirmed: " + result.Status + "/" + result.Error);
}
if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException();
// Presentation failure must not cause an already committed reward to replay.
foreach (var presentation in action.presentations)
{
if (!store.IsCurrent || token.IsCancellationRequested) throw new OperationCanceledException();
presentation();
}
kv.CompleteAction(actionKey);
}
}
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);
readonly HashSet<string> actions = new HashSet<string>(StringComparer.Ordinal);
internal bool BeginAction(string key) { lock (receiptsGate) return IsCurrent && actions.Add(key); }
internal void CompleteAction(string key) { lock (receiptsGate) actions.Remove(key); }
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 (!PlayerSaveAction.Accepts(this)) return new PlayerSaveWriteResult("ForeignStoreInReward");
if (!IsCurrent)
{
var rejected = new PlayerSaveWriteResult("StaleOrNotReady");
PlayerSaveAction.Record(this, key, rejected);
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());
PlayerSaveAction.Record(this, key, write);
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(); actions.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));
}
}
}