feat(upserver): 保存M2客户端KV迁移至本人统计写检查点
保存独立KV用户服务、固定会话存档与原业务接线,以及C3d资料缓存和C3e本人统计写。 保留原PF/AGG路线、业务公式和时序;榜单读取、好友资料、公告、匹配及首场景闸仍待完成。 验证:ServiceHost两条件各33组646断言;Unity CLI Loop编译0错误269警告。 包含相关方案、评审裁决、测试及状态文档。未操作服务器、正式账号或打包。
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
using asap.core;
|
||||
using asap.core;
|
||||
using com.fpnn;
|
||||
using com.fpnn.rtm;
|
||||
using GameCore;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using asap.core;
|
||||
using asap.core;
|
||||
using cfg;
|
||||
using GameCore;
|
||||
using System;
|
||||
@@ -32,6 +32,57 @@ namespace game
|
||||
}
|
||||
public class ClubService //: IClubService
|
||||
{
|
||||
[Inject] public PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
|
||||
// BEGIN B01 fixed prompt owner
|
||||
readonly bool requiresFormalKV;
|
||||
PlayerDataStorage.PlayerKVGameSessionHandle promptOwner;
|
||||
internal PlayerDataStorage.PlayerKVEditResult LastPromptEdit { get; private set; }
|
||||
public ClubService() : this(RequireFrozenSelection()) { }
|
||||
static bool RequireFrozenSelection()
|
||||
{
|
||||
var selection = GContext.container.Resolve<PlayerDataStorage.PlayerKVRuntimeSelection>();
|
||||
if (selection == null) throw DefaultSettingService.PromptFailure("IsOpenClubRed: startup selection missing.");
|
||||
return selection.IsKV;
|
||||
}
|
||||
internal ClubService(bool formalKV) { requiresFormalKV = formalKV; }
|
||||
internal ClubService(PlayerDataStorage.PlayerKVGameSessionHandle owner) : this(true)
|
||||
{
|
||||
promptOwner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
RequirePromptOwner();
|
||||
}
|
||||
// The constructor captures the registered startup selection, never a later mutable config value.
|
||||
internal void PreflightFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
|
||||
{
|
||||
if (!requiresFormalKV || owner == null || (promptOwner != null && !ReferenceEquals(promptOwner, owner)))
|
||||
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner mismatch.");
|
||||
var read = KVMgr.ReadKV(owner, "IsOpenClubRed");
|
||||
if (!read.Success)
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={owner.OwnerInstanceId}: {read.Error}");
|
||||
}
|
||||
internal void BindFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
|
||||
{
|
||||
PreflightFormalPromptOwner(owner);
|
||||
if (promptOwner == null) promptOwner = owner;
|
||||
RequirePromptOwner();
|
||||
}
|
||||
internal bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) =>
|
||||
requiresFormalKV && ReferenceEquals(promptOwner, owner);
|
||||
internal void RequirePromptOwner()
|
||||
{
|
||||
if (promptOwner != null) ReadPrompt();
|
||||
else if (requiresFormalKV)
|
||||
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner not handed off.");
|
||||
}
|
||||
PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt()
|
||||
{
|
||||
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
var read = KVMgr.ReadKV(promptOwner, "IsOpenClubRed");
|
||||
if (!read.Success)
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {read.Error}");
|
||||
return read;
|
||||
}
|
||||
// END B01 fixed prompt owner
|
||||
[Inject]
|
||||
public IUserService userService { get; set; }
|
||||
[Inject]
|
||||
@@ -137,7 +188,7 @@ namespace game
|
||||
}
|
||||
void SaveLureDonateData()
|
||||
{
|
||||
PlayFabMgr.Instance.UpdateUserDataValue("LureDonateData", Newtonsoft.Json.JsonConvert.SerializeObject(lureDonateData));
|
||||
SaveStore.Write("LureDonateData", Newtonsoft.Json.JsonConvert.SerializeObject(lureDonateData)).RequireAccepted();
|
||||
}
|
||||
//刷新体力求助次数
|
||||
public void RefreshLureDonate()
|
||||
@@ -152,6 +203,22 @@ namespace game
|
||||
|
||||
public void SetOpenClubRed()
|
||||
{
|
||||
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
|
||||
if (promptOwner != null)
|
||||
{
|
||||
var read = ReadPrompt();
|
||||
// M14: any present string means read, including "0" and the empty string.
|
||||
if (read.Found != true)
|
||||
{
|
||||
LastPromptEdit = KVMgr.WriteKV(promptOwner, "IsOpenClubRed", "1");
|
||||
if (!LastPromptEdit.Accepted)
|
||||
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
}
|
||||
RequirePromptOwner();
|
||||
IsOpenClubRed = false;
|
||||
SetClubRed();
|
||||
return;
|
||||
}
|
||||
if (IsOpenClubRed)
|
||||
{
|
||||
IsOpenClubRed = false;
|
||||
@@ -623,7 +690,7 @@ namespace game
|
||||
{
|
||||
if (!TryReadClubResponse<ClubSinginResp>(json, out var clubSinginResp, 0)) return;
|
||||
signTime = ZZTimeHelper.UtcNow().UtcNowOffset();
|
||||
PlayFabMgr.Instance.UpdateUserDataValue("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
SaveStore.Write("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss")).RequireAccepted();
|
||||
if (clubSinginResp.code == 0)
|
||||
{
|
||||
chatService.SendSystemMessage($"{MessageChatType.SI}_{apiPlayerId}");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Generic;
|
||||
using asap.core;
|
||||
using cfg;
|
||||
using game;
|
||||
@@ -20,6 +20,8 @@ public interface IDeferredRewardStashService
|
||||
|
||||
public class DeferredRewardStashService : IDeferredRewardStashService
|
||||
{
|
||||
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>();
|
||||
|
||||
private readonly TbItem _itemTable = GContext.container.Resolve<Tables>().TbItem;
|
||||
private readonly PlayerItemData _playerItemData = GContext.container.Resolve<PlayerItemData>();
|
||||
private CompositeDisposable _disposables;
|
||||
@@ -190,13 +192,13 @@ public class DeferredRewardStashService : IDeferredRewardStashService
|
||||
public void UploadData()
|
||||
{
|
||||
var s = Serialize();
|
||||
PlayFabMgr.Instance.UpdateUserDataValue(Key, s);
|
||||
saveStore.Write(Key, s).RequireAccepted();
|
||||
}
|
||||
|
||||
public void LoadData()
|
||||
{
|
||||
var s = saveStore.Read(Key).ValueOrThrow();
|
||||
Reset();
|
||||
var s = PlayFabMgr.Instance.GetLocalData(Key);
|
||||
if (string.IsNullOrEmpty(s))
|
||||
return;
|
||||
_items = Deserialize(s);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
public interface IFootPrintService
|
||||
{
|
||||
public EFootPrint FootPrint { get; }
|
||||
@@ -11,6 +11,8 @@ public interface IFootPrintService
|
||||
// ReSharper disable once ClassNeverInstantiated.Global
|
||||
public class FootPrintService : IFootPrintService
|
||||
{
|
||||
[asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
|
||||
|
||||
private EFootPrint _footPrint;
|
||||
private ulong _kvCompleteFootPrint;
|
||||
internal ulong CompleteFootPrintForKVValidation => _kvCompleteFootPrint;
|
||||
@@ -39,13 +41,15 @@ public class FootPrintService : IFootPrintService
|
||||
|
||||
public void UploadData()
|
||||
{
|
||||
var data = (ulong)_footPrint;
|
||||
PlayFabMgr.Instance.UpdateUserDataValue("FootPrint", data.ToString());
|
||||
_kvCompleteFootPrint |= unchecked((uint)(int)_footPrint);
|
||||
var data = _kvCompleteFootPrint;
|
||||
SaveStore.Write("FootPrint", data.ToString()).RequireAccepted();
|
||||
}
|
||||
|
||||
public void LoadData(string data)
|
||||
{
|
||||
_footPrint = (EFootPrint) ulong.Parse(data);
|
||||
_kvCompleteFootPrint = ulong.Parse(data);
|
||||
_footPrint = unchecked((EFootPrint)(int)(uint)_kvCompleteFootPrint);
|
||||
}
|
||||
|
||||
public bool HasFootPrint(EFootPrint state)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using asap.core;
|
||||
using asap.core;
|
||||
using asap.playfab.async;
|
||||
using GameCore;
|
||||
using PlayFab.ClientModels;
|
||||
@@ -14,6 +14,7 @@ namespace game
|
||||
{
|
||||
public class FriendService
|
||||
{
|
||||
[Inject] public PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
|
||||
[Inject]
|
||||
public IUserService userService { get; set; }
|
||||
|
||||
@@ -45,7 +46,7 @@ namespace game
|
||||
{
|
||||
List<cfg.Invite> invites = tables.TbInvite.DataList;
|
||||
//已经领取奖励的数量
|
||||
string receiveRewardCountStr = PlayFabMgr.Instance.GetLocalData(InviteLinkRewardCountKey);
|
||||
string receiveRewardCountStr = SaveStore.Read(InviteLinkRewardCountKey).ValueOrThrow();
|
||||
int.TryParse(receiveRewardCountStr, out InviteLinkRewardCount);
|
||||
int canRewardCount = InviteLinkRewardCount;
|
||||
for (int i = InviteLinkRewardCount; i < invites.Count; i++)
|
||||
@@ -93,7 +94,7 @@ namespace game
|
||||
GContext.container.Resolve<PlayerItemData>().AddItemByDropList(DropIDs, null);
|
||||
GContext.Publish(new ShowData());
|
||||
InviteLinkRewardCount = canRewardCount;
|
||||
PlayFabMgr.Instance.UpdateUserDataValue(InviteLinkRewardCountKey, InviteLinkRewardCount.ToString());
|
||||
SaveStore.Write(InviteLinkRewardCountKey, InviteLinkRewardCount.ToString()).RequireAccepted();
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.Home_Invite, false);
|
||||
#if AGG
|
||||
using (var e = GEvent.GameEvent("invite_reward"))
|
||||
|
||||
@@ -37,14 +37,68 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
#endif
|
||||
bool isInit = false;
|
||||
bool initing = false;
|
||||
DefaultSettingService promptSettings;
|
||||
Task initialization;
|
||||
Task opening;
|
||||
internal bool HasKVPromptOwner => promptSettings != null;
|
||||
public LocalNotificationService()
|
||||
{
|
||||
_notificationList = GContext.container.Resolve<cfg.Tables>().TbNotification.DataList;
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task Init()
|
||||
internal LocalNotificationService(cfg.Tables tables)
|
||||
{
|
||||
if (isInit) return;
|
||||
_notificationList = (tables ?? throw new ArgumentNullException(nameof(tables))).TbNotification.DataList;
|
||||
}
|
||||
|
||||
internal LocalNotificationService(DefaultSettingService settings, cfg.Tables tables)
|
||||
{
|
||||
if (settings == null || !settings.HasKVPromptOwner)
|
||||
throw new ArgumentException("Fixed KV settings required.", nameof(settings));
|
||||
promptSettings = settings;
|
||||
_notificationList = (tables ?? throw new ArgumentNullException(nameof(tables))).TbNotification.DataList;
|
||||
RequirePromptOwner();
|
||||
}
|
||||
|
||||
internal void RequirePromptOwner(ISettingService expected = null)
|
||||
{
|
||||
if (expected != null && (promptSettings != null || expected is DefaultSettingService setting && setting.HasKVPromptOwner)
|
||||
&& !ReferenceEquals(expected, promptSettings))
|
||||
throw DefaultSettingService.PromptFailure("ShowNotice: notification/settings owner mismatch.");
|
||||
promptSettings?.RequirePromptOwner();
|
||||
}
|
||||
|
||||
internal void PreflightFormalPromptSettings(DefaultSettingService settings)
|
||||
{
|
||||
if (settings == null || !isInit || initing ||
|
||||
(initialization != null && !initialization.IsCompleted) ||
|
||||
(opening != null && !opening.IsCompleted) ||
|
||||
(promptSettings != null && !ReferenceEquals(promptSettings, settings)))
|
||||
throw DefaultSettingService.PromptFailure("ShowNotice: notification handoff is not idle.");
|
||||
}
|
||||
internal void BindFormalPromptSettings(DefaultSettingService settings)
|
||||
{
|
||||
PreflightFormalPromptSettings(settings);
|
||||
if (!settings.HasKVPromptOwner)
|
||||
throw DefaultSettingService.PromptFailure("ShowNotice: notification owner not handed off.");
|
||||
settings.RequirePromptOwner();
|
||||
if (promptSettings == null) promptSettings = settings;
|
||||
RequirePromptOwner(settings);
|
||||
}
|
||||
internal bool HasFormalPromptSettings(DefaultSettingService settings) =>
|
||||
ReferenceEquals(promptSettings, settings);
|
||||
|
||||
public Task Init()
|
||||
{
|
||||
RequirePromptOwner();
|
||||
if (isInit) return Task.CompletedTask;
|
||||
if (initialization != null && !initialization.IsCompleted) return initialization;
|
||||
return initialization = InitCoreAsync();
|
||||
}
|
||||
|
||||
async Task InitCoreAsync()
|
||||
{
|
||||
RequirePromptOwner();
|
||||
#if UNITY_EDITOR
|
||||
await System.Threading.Tasks.Task.Yield();
|
||||
#else
|
||||
@@ -59,9 +113,11 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
Debug.Log("[LocalNotificationService::Init] Channel Inited");
|
||||
|
||||
await NotificationCenter.RequestPermission();
|
||||
RequirePromptOwner();
|
||||
ScheduleDefaultNotification();
|
||||
GContext.OnEvent<EventRankData>().Subscribe(ScheduleRankNotification);
|
||||
#endif
|
||||
RequirePromptOwner();
|
||||
isInit = true;
|
||||
}
|
||||
|
||||
@@ -86,14 +142,36 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
|
||||
public async void OpenSettings()
|
||||
{
|
||||
if (initing) return;
|
||||
try { await OpenSettingsAsync(); }
|
||||
catch (Exception ex) { game.DefaultSettingService.LogPromptFailure(ex); }
|
||||
}
|
||||
|
||||
internal Task OpenSettingsAsync()
|
||||
{
|
||||
if (promptSettings == null && GContext.container.Resolve<ISettingService>() is DefaultSettingService pending)
|
||||
pending.RequirePromptOwner();
|
||||
RequirePromptOwner();
|
||||
if (initing && opening == null)
|
||||
throw DefaultSettingService.PromptFailure("ShowNotice: reentrant permission request.");
|
||||
if (opening != null && !opening.IsCompleted) return opening;
|
||||
return opening = OpenSettingsCoreAsync();
|
||||
}
|
||||
|
||||
async Task OpenSettingsCoreAsync()
|
||||
{
|
||||
ISettingService settings = promptSettings ?? GContext.container.Resolve<ISettingService>();
|
||||
RequirePromptOwner(settings);
|
||||
(settings as DefaultSettingService)?.RequirePromptOwner();
|
||||
initing = true;
|
||||
if (!isInit)
|
||||
await Init();
|
||||
else
|
||||
NotificationCenter.OpenNotificationSettings();
|
||||
GContext.container.Resolve<ISettingService>().ShowNotice = true;
|
||||
initing = false;
|
||||
try
|
||||
{
|
||||
if (!isInit) await Init();
|
||||
else NotificationCenter.OpenNotificationSettings();
|
||||
RequirePromptOwner(settings);
|
||||
(settings as DefaultSettingService)?.RequirePromptOwner();
|
||||
settings.ShowNotice = true;
|
||||
}
|
||||
finally { initing = false; }
|
||||
}
|
||||
|
||||
public void ScheduleDailyNotification(int notificationID, DateTime fireTime, string title, string text)
|
||||
@@ -150,6 +228,8 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
|
||||
private void ScheduleRankNotification(EventRankData evt)
|
||||
{
|
||||
try { RequirePromptOwner(); }
|
||||
catch (InvalidOperationException ex) { Debug.LogError(ex.Message); return; }
|
||||
if (rankNotifyScheduled || !IsNoticeOpen || !isInit) return;
|
||||
|
||||
Debug.Log("[LocalNotificationService::SendNotification] RankEvent");
|
||||
@@ -170,4 +250,4 @@ public class LocalNotificationService : ILocalNotificationService
|
||||
rankNotifyScheduled = true;
|
||||
Debug.Log("[LocalNotificationService::SendRankEventNotification] ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
341
Client/Assets/Scripts/Services/KVUserService.cs
Normal file
341
Client/Assets/Scripts/Services/KVUserService.cs
Normal file
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using game.PlayerDataStorage;
|
||||
using GameCore;
|
||||
using tysdk;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game
|
||||
{
|
||||
// One service belongs to one login lifetime. A revoked instance is never rebound.
|
||||
public sealed class KVUserService : IUserService, IKVUserLoadReceiver, IKVUserLifecycle
|
||||
{
|
||||
[Inject] public ICustomServerMgr customServerMgr { get; set; }
|
||||
[Inject] public IPlayerSaveStore SaveStore { get; set; }
|
||||
readonly PlayerKVRuntimeSelection selection;
|
||||
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
readonly Dictionary<string, PlayInfo> profiles = new Dictionary<string, PlayInfo>();
|
||||
readonly Dictionary<string, int> levels = new Dictionary<string, int>();
|
||||
PlayerKVGameLoadCoordinator coordinator;
|
||||
PlayerKVGameSessionHandle handle;
|
||||
PlayerKVInitializationIntents intents;
|
||||
bool attempted, loading, closing, published, promptsBound;
|
||||
Task closeTask;
|
||||
long epoch;
|
||||
string playerId;
|
||||
uint internalId;
|
||||
public string CustomId { get; set; }
|
||||
public string UserId => IsAuthorized ? playerId : null;
|
||||
public string ApiPlayerId => UserId;
|
||||
public uint InternalId => IsAuthorized ? internalId : 0;
|
||||
public bool IsLogin => IsAuthorized;
|
||||
public bool IsAuthorized => published && KVHandoffReady;
|
||||
public CancellationToken LoginLifetime => lifetime.Token;
|
||||
public DateTime LastLoginTime { get; private set; }
|
||||
public DateTime LoginTime { get; private set; }
|
||||
public DateTime CreateTime { get; private set; }
|
||||
public string DisplayName { get; private set; }
|
||||
public string AvatarUrl { get; private set; }
|
||||
public string Language { get; private set; }
|
||||
public string ContinentCode { get; private set; }
|
||||
public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>();
|
||||
internal int KVTapJoyBalance { get; private set; }
|
||||
internal PlayerKVGameLoadError KVLastError { get; private set; }
|
||||
internal bool KVHandoffReady => !closing && handle != null && coordinator != null &&
|
||||
coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle);
|
||||
bool IKVUserLifecycle.KVHandoffReady => KVHandoffReady;
|
||||
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
|
||||
|
||||
public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
||||
|
||||
internal KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{
|
||||
this.selection = selection;
|
||||
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
|
||||
Application.quitting += Logout;
|
||||
}
|
||||
|
||||
public async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
|
||||
{
|
||||
if (attempted || closing || selection == null || !selection.IsKV ||
|
||||
!selection.TryAdmitPlayer(CustomId, out var canonicalId))
|
||||
{ KVLastError = PlayerKVGameLoadError.Input; return ELoginStep.Api; }
|
||||
// Set before the first await: duplicate calls cannot authenticate or subscribe twice.
|
||||
attempted = true;
|
||||
var requestedId = CustomId;
|
||||
try
|
||||
{
|
||||
if (customServerMgr is not CustomServerMgr server ||
|
||||
server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
|
||||
throw new InvalidOperationException("Formal KV transport required.");
|
||||
loading = true;
|
||||
var transaction = PlayerKVGameplayBindings.Bind(this);
|
||||
coordinator = new PlayerKVGameLoadCoordinator(transaction,
|
||||
() => loading && !closing && !published && handle == null,
|
||||
() => { }, () => intents?.Count ?? 0, server.BindKVForGame, server.ClearKVForGame);
|
||||
server.KVUnauthorized += OnUnauthorized;
|
||||
var identity = new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none,
|
||||
Array.Empty<PlayerKVIdentityChannel>());
|
||||
var snapshot = PlayerKVGameplayBindings.Snapshot(DateTime.UtcNow, "formal-v1");
|
||||
var capability = PlayerKVGameLoadCapability.CreateForGame(identity, snapshot, selection.Api,
|
||||
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory);
|
||||
var result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
|
||||
() => !closing && CustomId == requestedId);
|
||||
KVLastError = result.Error;
|
||||
if (result.State != PlayerKVGameLoadState.GameLoaded || closing || CustomId != requestedId ||
|
||||
result.Account == null || result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
|
||||
!coordinator.TryGetCurrentGameHandle(result.Epoch, out var original))
|
||||
{ Logout(); return ELoginStep.Api; }
|
||||
epoch = result.Epoch;
|
||||
handle = original;
|
||||
if (SaveStore is not KVPlayerSaveStore store) throw new InvalidOperationException("KV save store required.");
|
||||
store.Bind(original, LoginLifetime);
|
||||
PrepareProfile(result.Account);
|
||||
BindPromptServices(original);
|
||||
// Chat subscribes before the unique success event. No SDK or analytics initialization here.
|
||||
GContext.container.Resolve<IChatService>().WaitingForLogin();
|
||||
if (!KVHandoffReady || CustomId != requestedId) throw new InvalidOperationException("Stale KV login.");
|
||||
server.EnableKVBusiness(epoch);
|
||||
playerId = result.Account.PlayerId;
|
||||
internalId = result.Account.InternalId;
|
||||
published = true;
|
||||
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId });
|
||||
if (!IsAuthorized) return ELoginStep.Api;
|
||||
_ = RunSaveScheduleAsync(store);
|
||||
progress?.Invoke(2);
|
||||
return ELoginStep.Done;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
KVLastError = PlayerKVGameLoadError.Apply;
|
||||
Debug.LogError("KV login failed: " + ex.GetType().Name);
|
||||
Logout();
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
finally { loading = false; }
|
||||
}
|
||||
|
||||
void PrepareProfile(PlayerKVAccountDescriptor account)
|
||||
{
|
||||
if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue)
|
||||
throw new InvalidOperationException("Authenticated account metadata required.");
|
||||
CreateTime = account.CreateTimeUtc;
|
||||
LoginTime = account.ServerTimeUtc;
|
||||
var previous = Read("LastLoginTimeUtc");
|
||||
LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value);
|
||||
DisplayName = Read("DisplayName").Value;
|
||||
if (string.IsNullOrEmpty(DisplayName)) DisplayName = GetDefaultName(account.PlayerId);
|
||||
AvatarUrl = Read("AvatarUrl").Value ?? string.Empty;
|
||||
Language = Read("Language").Value;
|
||||
if (string.IsNullOrEmpty(Language)) Language = GContext.container.Resolve<ISettingService>().Lang;
|
||||
var continent = Read("ContinentCode").Value;
|
||||
ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null;
|
||||
}
|
||||
|
||||
internal static DateTime ParseLastLoginTime(string value, bool found)
|
||||
{
|
||||
if (!found) return DateTime.MinValue;
|
||||
if (!DateTime.TryParseExact(value, "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var time) ||
|
||||
time == DateTime.MinValue || time.Kind == DateTimeKind.Unspecified)
|
||||
throw new FormatException("Invalid LastLoginTimeUtc.");
|
||||
return time.ToUniversalTime();
|
||||
}
|
||||
|
||||
PlayerSaveReadResult Read(string key)
|
||||
{
|
||||
var result = SaveStore.Read(key);
|
||||
if (!result.Success) throw new InvalidOperationException("KV profile read failed: " + result.Error);
|
||||
return result;
|
||||
}
|
||||
|
||||
void BindPromptServices(PlayerKVGameSessionHandle original)
|
||||
{
|
||||
if (promptsBound || !KVHandoffReady) throw new InvalidOperationException("Prompt binding is stale.");
|
||||
var settings = GContext.container.Resolve<ISettingService>() as DefaultSettingService;
|
||||
var notifications = GContext.container.Resolve<ILocalNotificationService>() as LocalNotificationService;
|
||||
var club = GContext.container.Resolve<ClubService>();
|
||||
if (settings == null || notifications == null || club == null ||
|
||||
!ReferenceEquals(settings, GContext.container.Resolve<ISettingService>()) ||
|
||||
!ReferenceEquals(notifications, GContext.container.Resolve<ILocalNotificationService>()) ||
|
||||
!ReferenceEquals(club, GContext.container.Resolve<ClubService>()))
|
||||
throw new InvalidOperationException("Formal prompt services required.");
|
||||
settings.PreflightFormalPromptOwner(original);
|
||||
club.PreflightFormalPromptOwner(original);
|
||||
notifications.PreflightFormalPromptSettings(settings);
|
||||
promptsBound = true;
|
||||
settings.BindFormalPromptOwner(original);
|
||||
club.BindFormalPromptOwner(original);
|
||||
notifications.BindFormalPromptSettings(settings);
|
||||
if (!settings.HasFormalPromptOwner(original) || !club.HasFormalPromptOwner(original) ||
|
||||
!notifications.HasFormalPromptSettings(settings) || !KVHandoffReady)
|
||||
throw new InvalidOperationException("Formal prompt binding changed.");
|
||||
}
|
||||
|
||||
void OnUnauthorized(int version)
|
||||
{
|
||||
if (version == customServerMgr.SessionVersion) Logout();
|
||||
}
|
||||
|
||||
public void Logout() => ObserveClose(CloseKVForValidationAsync());
|
||||
async void ObserveClose(Task task)
|
||||
{
|
||||
try { await task; }
|
||||
catch (Exception ex) { Debug.LogError("KV close failed: " + ex.GetType().Name); }
|
||||
}
|
||||
internal Task CloseKVForValidationAsync()
|
||||
{
|
||||
if (closeTask != null) return closeTask;
|
||||
closing = true;
|
||||
published = false;
|
||||
(SaveStore as KVPlayerSaveStore)?.Dispose();
|
||||
playerId = null; internalId = 0; handle = null; epoch = 0;
|
||||
DisplayName = AvatarUrl = Language = ContinentCode = null;
|
||||
ClearPlayInfos();
|
||||
Application.quitting -= Logout;
|
||||
if (customServerMgr is CustomServerMgr server)
|
||||
{
|
||||
server.KVUnauthorized -= OnUnauthorized;
|
||||
server.DisableKVBusiness();
|
||||
}
|
||||
// Revoke first; a synchronous cancellation callback must already see invalid identity.
|
||||
closeTask = coordinator?.Revoke() ?? Task.CompletedTask;
|
||||
lifetime.Cancel();
|
||||
return closeTask;
|
||||
}
|
||||
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
|
||||
internal Task<PlayerKVShutdownStatus> ObserveKVQuitAsync() =>
|
||||
coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
|
||||
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
|
||||
|
||||
void IKVUserLoadReceiver.ResetForKVLoad()
|
||||
{
|
||||
if ((!loading && !closing) || published || handle != null)
|
||||
throw new InvalidOperationException("KV identity must be unpublished during load/reset.");
|
||||
intents?.Discard(); intents = null;
|
||||
EAccoutUrl = new Dictionary<string, string>(); KVTapJoyBalance = 0;
|
||||
}
|
||||
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state, long loadEpoch, IReadOnlyDictionary<string, string> raw)
|
||||
{
|
||||
if (!loading || closing || published || handle != null || state == null || raw == null)
|
||||
throw new InvalidOperationException("KV load receiver is not current.");
|
||||
intents = new PlayerKVInitializationIntents(loadEpoch, state.Channel);
|
||||
foreach (var pair in state.Normalizations) intents.Add(loadEpoch, pair.Key, pair.Value);
|
||||
EAccoutUrl = state.AccountUrls.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
||||
KVTapJoyBalance = state.TapJoyBalance;
|
||||
}
|
||||
void IKVUserLoadReceiver.DiscardKVIntents() => intents?.Discard();
|
||||
|
||||
async Task SaveProfile(string key, string value)
|
||||
{
|
||||
var store = SaveStore;
|
||||
if (!IsAuthorized || store == null) throw new InvalidOperationException("KV identity unavailable.");
|
||||
var result = await store.WriteAndConfirmAsync(key, value, LoginLifetime);
|
||||
if (!IsAuthorized || !ReferenceEquals(store, SaveStore) || !store.IsCurrent || !result.Confirmed)
|
||||
throw new InvalidOperationException("KV profile save was not confirmed.");
|
||||
}
|
||||
|
||||
async Task RunSaveScheduleAsync(IPlayerSaveStore store)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (IsAuthorized && store.IsCurrent)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(40), LoginLifetime);
|
||||
if (!IsAuthorized || !store.IsCurrent) return;
|
||||
var result = await store.FlushAsync(LoginLifetime);
|
||||
if (!result.Confirmed && !LoginLifetime.IsCancellationRequested)
|
||||
Debug.LogWarning("KV scheduled save: " + result.Status);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex) { Debug.LogError("KV save scheduler failed: " + ex.GetType().Name); }
|
||||
}
|
||||
public async Task<string> ChangeDisplayName(string newName)
|
||||
{
|
||||
await SaveProfile("DisplayName", newName);
|
||||
DisplayName = newName; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return newName;
|
||||
}
|
||||
public async Task<string> UpdateAvatarUrl(string url)
|
||||
{
|
||||
await SaveProfile("AvatarUrl", url);
|
||||
AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return url;
|
||||
}
|
||||
public async Task<string> UpdateLanguage(string code)
|
||||
{ await SaveProfile("Language", code); Language = code; return code; }
|
||||
public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds;
|
||||
// The non-AGG channel has no account-link operation; never manufacture a successful binding.
|
||||
public Task<(int code, int userid)> LinkAccount(EAccoutType type) => Task.FromResult((1, 0));
|
||||
public Task<bool> GetLinkState(EAccoutType type) => Task.FromResult(false);
|
||||
public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
|
||||
public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve<PlayerData>().lv : level;
|
||||
public int GetPlayerLv(string id) => levels.TryGetValue(id, out var level) ? level : 1;
|
||||
public Task<(string, int)> GetPlayLv(string id)
|
||||
{
|
||||
var level = id == UserId && IsAuthorized ? (int)GContext.container.Resolve<PlayerData>().lv :
|
||||
id != null && levels.TryGetValue(id, out var cachedLevel) ? cachedLevel : -1;
|
||||
return level >= 0 ? Task.FromResult((id, level)) :
|
||||
Task.FromException<(string, int)>(new InvalidOperationException("Public player level has not been loaded."));
|
||||
}
|
||||
public PlayInfo GetPlayInfo(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return null;
|
||||
if (!profiles.TryGetValue(id, out var profile))
|
||||
{
|
||||
profile = new PlayInfo { name = GetDefaultName(id) };
|
||||
profiles.Add(id, profile);
|
||||
SetPlayInfo(id, profile);
|
||||
}
|
||||
return profile;
|
||||
}
|
||||
async void SetPlayInfo(string id, PlayInfo profile)
|
||||
{
|
||||
if (id == UserId)
|
||||
{
|
||||
profile.name = DisplayName;
|
||||
profile.avatarUrl = AvatarUrl;
|
||||
GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl });
|
||||
return;
|
||||
}
|
||||
var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id });
|
||||
if (string.IsNullOrEmpty(json)) return;
|
||||
SearchFriendResp result;
|
||||
try { result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json); }
|
||||
catch (Newtonsoft.Json.JsonException ex)
|
||||
{
|
||||
Debug.LogError("KV player profile response failed: " + ex.GetType().Name);
|
||||
return;
|
||||
}
|
||||
if (result != null && result.Code > 0 && result.Info != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(result.Info.DisplayName)) profile.name = result.Info.DisplayName;
|
||||
profile.avatarUrl = result.Info.AvatarUrl;
|
||||
GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl });
|
||||
}
|
||||
}
|
||||
public void SetPlayInfo(string id, string name, string avatarUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id)) return;
|
||||
profiles[id] = new PlayInfo { name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name, avatarUrl = avatarUrl };
|
||||
}
|
||||
public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); }
|
||||
public void UpdateProfile()
|
||||
{
|
||||
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv, Avatar = AvatarUrl, DisplayName = DisplayName } });
|
||||
}
|
||||
public void UpdateProfileLv()
|
||||
{
|
||||
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,
|
||||
playerProfile = new PlayerProfile { Level = GContext.container.Resolve<PlayerData>().lv } });
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Client/Assets/Scripts/Services/KVUserService.cs.meta
Normal file
2
Client/Assets/Scripts/Services/KVUserService.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e146290e7ecd4db58dbeb536ace8a092
|
||||
@@ -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:
|
||||
@@ -3,6 +3,8 @@ using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using Game;
|
||||
using UnityEngine;
|
||||
using System;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
namespace game
|
||||
{
|
||||
@@ -27,6 +29,61 @@ namespace game
|
||||
int _unitOfWeight = 1;
|
||||
bool? _showNotice;
|
||||
|
||||
readonly bool requiresFormalKV;
|
||||
PlayerKVGameSessionHandle promptOwner;
|
||||
internal PlayerKVEditResult LastPromptEdit { get; private set; }
|
||||
internal bool HasKVPromptOwner => promptOwner != null;
|
||||
|
||||
// Preserve only our key/owner/error metadata across void UI boundaries, never platform payloads.
|
||||
internal static InvalidOperationException PromptFailure(string message)
|
||||
{
|
||||
var error = new InvalidOperationException(message);
|
||||
error.Data["B01PromptDiagnostic"] = message;
|
||||
return error;
|
||||
}
|
||||
internal static void LogPromptFailure(Exception error) => Debug.LogError(
|
||||
error.Data["B01PromptDiagnostic"] as string ?? "Notification prompt stopped: " + error.GetType().Name);
|
||||
|
||||
public DefaultSettingService() : this(false) { }
|
||||
internal DefaultSettingService(bool formalKV) { requiresFormalKV = formalKV; }
|
||||
internal DefaultSettingService(PlayerKVGameSessionHandle owner) : this(true)
|
||||
{
|
||||
promptOwner = owner ?? throw new ArgumentNullException(nameof(owner));
|
||||
RequirePromptOwner();
|
||||
}
|
||||
|
||||
// The frozen startup selection is supplied before login; this instance can never change owners.
|
||||
internal void PreflightFormalPromptOwner(PlayerKVGameSessionHandle owner)
|
||||
{
|
||||
if (!requiresFormalKV || owner == null || (promptOwner != null && !ReferenceEquals(promptOwner, owner)))
|
||||
throw PromptFailure("ShowNotice: formal owner mismatch.");
|
||||
var read = KVMgr.ReadKV(owner, "ShowNotice");
|
||||
if (!read.Success) throw PromptFailure($"ShowNotice owner={owner.OwnerInstanceId}: {read.Error}");
|
||||
}
|
||||
internal void BindFormalPromptOwner(PlayerKVGameSessionHandle owner)
|
||||
{
|
||||
PreflightFormalPromptOwner(owner);
|
||||
if (promptOwner == null) promptOwner = owner;
|
||||
RequirePromptOwner();
|
||||
}
|
||||
internal bool HasFormalPromptOwner(PlayerKVGameSessionHandle owner) =>
|
||||
requiresFormalKV && ReferenceEquals(promptOwner, owner);
|
||||
internal void RequirePromptOwner()
|
||||
{
|
||||
if (promptOwner != null) ReadPrompt();
|
||||
else if (requiresFormalKV) throw PromptFailure("ShowNotice: formal owner not handed off.");
|
||||
}
|
||||
|
||||
PlayerKVKeyReadResult ReadPrompt()
|
||||
{
|
||||
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
var read = KVMgr.ReadKV(promptOwner, "ShowNotice");
|
||||
if (!read.Success)
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {read.Error}");
|
||||
return read;
|
||||
}
|
||||
|
||||
public bool Music
|
||||
{
|
||||
set
|
||||
@@ -105,11 +162,33 @@ namespace game
|
||||
{
|
||||
get
|
||||
{
|
||||
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
|
||||
if (promptOwner != null)
|
||||
{
|
||||
var read = ReadPrompt();
|
||||
_showNotice = read.Found == true && read.Value == "1";
|
||||
return _showNotice.Value;
|
||||
}
|
||||
_showNotice ??= PlayFabMgr.Instance.GetLocalData("ShowNotice") == "1";
|
||||
return _showNotice.Value;
|
||||
}
|
||||
set
|
||||
{
|
||||
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
|
||||
if (promptOwner != null)
|
||||
{
|
||||
var read = ReadPrompt();
|
||||
var serialized = value ? "1" : "0";
|
||||
if (read.Found != true || read.Value != serialized)
|
||||
{
|
||||
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", serialized);
|
||||
if (!LastPromptEdit.Accepted)
|
||||
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
|
||||
}
|
||||
RequirePromptOwner();
|
||||
_showNotice = value;
|
||||
return;
|
||||
}
|
||||
if (value != _showNotice)
|
||||
{
|
||||
_showNotice = value;
|
||||
@@ -156,7 +235,9 @@ namespace game
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (GContext.container.Resolve<ILocalNotificationService>().IsNoticeOpen)
|
||||
var notification = GContext.container.Resolve<ILocalNotificationService>();
|
||||
if (notification is LocalNotificationService local) local.RequirePromptOwner(this);
|
||||
if (notification.IsNoticeOpen)
|
||||
{
|
||||
ShowNotice = true;
|
||||
return false;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using asap.core;
|
||||
using asap.core;
|
||||
using cfg;
|
||||
using game;
|
||||
using GameCore;
|
||||
@@ -21,23 +21,25 @@ public class SurveyService
|
||||
const string SurveyData = "SurveyData";
|
||||
const string SurveyReadSidData = "SurveyReadSidData";
|
||||
|
||||
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore;
|
||||
IUserService userService;
|
||||
cfg.Tables tables;
|
||||
List<string> receivedReward = new List<string>();
|
||||
List<string> readSid = new List<string>();
|
||||
string access_token;
|
||||
public SurveyData tableData;
|
||||
public SurveyService(IUserService userService, cfg.Tables tables)
|
||||
public SurveyService(IUserService userService, cfg.Tables tables, game.PlayerDataStorage.IPlayerSaveStore saveStore)
|
||||
{
|
||||
this.userService = userService;
|
||||
this.saveStore = saveStore;
|
||||
this.tables = tables;
|
||||
tableData = null;
|
||||
string surveyData = PlayFabMgr.Instance.GetLocalData(SurveyData);
|
||||
string surveyData = saveStore.Read(SurveyData).ValueOrThrow();
|
||||
if (!string.IsNullOrEmpty(surveyData))
|
||||
{
|
||||
receivedReward = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(surveyData);
|
||||
}
|
||||
string surveyReadSidData = PlayFabMgr.Instance.GetLocalData(SurveyReadSidData);
|
||||
string surveyReadSidData = saveStore.Read(SurveyReadSidData).ValueOrThrow();
|
||||
if (!string.IsNullOrEmpty(surveyReadSidData))
|
||||
{
|
||||
readSid = Newtonsoft.Json.JsonConvert.DeserializeObject<List<string>>(surveyReadSidData);
|
||||
@@ -53,7 +55,7 @@ public class SurveyService
|
||||
PlayerData playerData = GContext.container.Resolve<PlayerData>();
|
||||
|
||||
List<SurveyData> surveyDataTable = tables.TbSurveyData.DataList;
|
||||
ulong userId = Convert.ToUInt64(userService.UserId, 16);
|
||||
ulong userId = Convert.ToUInt64(userService.UserId, saveStore is game.PlayerDataStorage.KVPlayerSaveStore ? 10 : 16);
|
||||
tableData = null;
|
||||
if (surveyDataTable.Count > 0)
|
||||
{
|
||||
@@ -109,7 +111,7 @@ public class SurveyService
|
||||
string url = $"{SurveyUrl}s2/{tableData.ID}/{tableData.Hash}/?&uid={userService.CustomId}";
|
||||
//标记已读
|
||||
readSid.Add(tableData.ID.ToString());
|
||||
PlayFabMgr.Instance.UpdateUserDataValue(SurveyReadSidData, Newtonsoft.Json.JsonConvert.SerializeObject(readSid));
|
||||
saveStore.Write(SurveyReadSidData, Newtonsoft.Json.JsonConvert.SerializeObject(readSid)).RequireAccepted();
|
||||
GetSurveyMatchlist();
|
||||
return url;
|
||||
}
|
||||
@@ -133,7 +135,7 @@ public class SurveyService
|
||||
GContext.container.Resolve<PlayerItemData>().AddItemByDrop(tableData.Reward, null);
|
||||
GContext.Publish(new ShowData());
|
||||
receivedReward.Add(sid);
|
||||
PlayFabMgr.Instance.UpdateUserDataValue(SurveyData, Newtonsoft.Json.JsonConvert.SerializeObject(receivedReward));
|
||||
saveStore.Write(SurveyData, Newtonsoft.Json.JsonConvert.SerializeObject(receivedReward)).RequireAccepted();
|
||||
GetSurveyMatchlist();
|
||||
}
|
||||
catch (Exception e)
|
||||
|
||||
@@ -253,7 +253,7 @@ namespace game.PlayerDataStorage
|
||||
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
var owner=a.Consumption.Owner;
|
||||
var flush=await PlayFabMgr.FlushKVAsync(owner,timeout.Token);
|
||||
var flush=await KVMgr.FlushKVAsync(owner,timeout.Token);
|
||||
if(!RewardCurrent(a) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
|
||||
throw Stop("Reward flush: "+flush.TransportError);
|
||||
foreach(var r in a.Records)
|
||||
@@ -262,7 +262,7 @@ namespace game.PlayerDataStorage
|
||||
var final=last[r.Key].Edit.Ticket;
|
||||
if(t.OwnerInstanceId!=owner.OwnerInstanceId || t.Epoch!=owner.Epoch || t.Key!=r.Key ||
|
||||
final.OwnerInstanceId!=t.OwnerInstanceId || final.Epoch!=t.Epoch) throw Stop("Reward ticket identity mismatch.");
|
||||
var result=await PlayFabMgr.WaitForKVTicketAsync(owner,t,timeout.Token);
|
||||
var result=await KVMgr.WaitForKVTicketAsync(owner,t,timeout.Token);
|
||||
if(!RewardCurrent(a)) throw Stop("Reward action changed while waiting.");
|
||||
bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(t,final) && final.Sequence>t.Sequence;
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Reward ticket: "+result.Status);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -152,6 +152,9 @@ namespace game.PlayerDataStorage
|
||||
pfShell=new GameObject("KV inactive guide Init fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=pfShell.AddComponent<PlayFabMgr>();
|
||||
Check(pf.IsUnstartedForKVLoad,"guide fixture never starts PF lifecycle");pfField.SetValue(null,pf);
|
||||
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
player.playerSaveStore=store;eventData.playerSaveStore=store;guideState.playerSaveStore=store;
|
||||
var initErrors=new List<string>();var initWarnings=new List<string>();int completeEvents=0;
|
||||
Application.LogCallback onLog=(message,stack,type)=>{if(type==LogType.Exception || type==LogType.Error)initErrors.Add(message);if(type==LogType.Warning)initWarnings.Add(message);};
|
||||
Application.logMessageReceived+=onLog;
|
||||
@@ -270,6 +273,9 @@ namespace game.PlayerDataStorage
|
||||
typeof(PlayerFishData).GetField("mapFishData",fields).SetValue(fish,new Dictionary<int,Dictionary<int,FishWeightData>>{{102,data}});
|
||||
pfShell=new GameObject("KV reward unstarted PF fixture");pfShell.SetActive(false);pfShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=pfShell.AddComponent<PlayFabMgr>();Check(pf.IsUnstartedForKVLoad,"PF lifecycle never starts");pfField.SetValue(null,pf);
|
||||
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
player.playerSaveStore=store;fish.playerSaveStore=store;
|
||||
rewardAction.BeforeFishCount=scenario=="new-fish"?0:20;
|
||||
using(BeginRewardSegment("strike")){}rewardAction.Ending=true;using(BeginRewardSegment("ending")){}
|
||||
rewardAction.Applying=true;
|
||||
@@ -519,8 +525,14 @@ namespace game.PlayerDataStorage
|
||||
var pf=shell.AddComponent<PlayFabMgr>();
|
||||
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
|
||||
PF.SetValue(null,pf);
|
||||
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
container.Resolve<PlayerData>().playerSaveStore=store;
|
||||
container.Resolve<PlayerFishData>().playerSaveStore=store;
|
||||
container.Resolve<SmallGameData>().playerSaveStore=store;
|
||||
events.playerSaveStore=store;
|
||||
var items=new PlayerItemData(tables);container.RegisterInstance(items);
|
||||
var data=new BattlePassData(tables);
|
||||
var data=new BattlePassData(tables){playerSaveStore=store};
|
||||
data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID;
|
||||
data.BattlePassRecord.Level=1;
|
||||
foreach(var t in tables.TbBattlePassTask.DataList.Where(t=>rewardConditions.Contains(t.Type)))data.BattleTask.dic_dailyTasks.Add(t.TaskID,0);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -31,6 +31,9 @@ namespace game.PlayerDataStorage
|
||||
var oldLanguages=LocalizationMgr.enumLanguage;
|
||||
var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
var oldTextTable=textTable.GetValue(null);
|
||||
GameObject saveShell=null;
|
||||
var pfField=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
var oldPF=pfField.GetValue(null);
|
||||
FishingSubscribers subscribers=null;
|
||||
int checks=0,groups=0; var passed=new List<string>(); string result=null;
|
||||
void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); }
|
||||
@@ -40,6 +43,7 @@ namespace game.PlayerDataStorage
|
||||
player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null;
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pending!=null) { try { await pending; } catch { } pending=null; }
|
||||
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);saveShell=null;
|
||||
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null;
|
||||
fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString()));
|
||||
var owner=await fixture.Open();
|
||||
@@ -177,12 +181,17 @@ namespace game.PlayerDataStorage
|
||||
var tables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json")))));
|
||||
container=new Container(); FishingContextAccess.Use(container); FishingContextAccess.Events=new EventAggregator();
|
||||
container.RegisterInstance(tables);
|
||||
saveShell=new GameObject("KV inactive consumption storage fixture");saveShell.SetActive(false);saveShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=saveShell.AddComponent<PlayFabMgr>();Check(pf.IsUnstartedForKVLoad,"consumption fixture never starts PF lifecycle");pfField.SetValue(null,pf);
|
||||
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();
|
||||
textTable.SetValue(null,null);
|
||||
player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null);
|
||||
var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables);
|
||||
container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish);
|
||||
container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events);
|
||||
player.playerSaveStore=store;fish.playerSaveStore=store;small.playerSaveStore=store;events.playerSaveStore=store;
|
||||
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
|
||||
fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad();
|
||||
achievements.Init();
|
||||
@@ -228,7 +237,7 @@ namespace game.PlayerDataStorage
|
||||
Check(scenario=="cross-limit"?savedTime>new DateTime(2026,1,1):savedTime==new DateTime(2026,1,1),"recovery timestamp crossing behavior preserved");
|
||||
if(fixture.HoldSet)
|
||||
{
|
||||
await PlayFabMgr.FlushKVAsync(action.Owner);
|
||||
await KVMgr.FlushKVAsync(action.Owner);
|
||||
var recovered=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation");
|
||||
}
|
||||
@@ -254,6 +263,7 @@ namespace game.PlayerDataStorage
|
||||
player?.StopRecoveryEnergy(); subscription?.Dispose();
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pending!=null) { try { await pending; } catch(Exception e) { Debug.LogWarning("Observed fishing test failure: "+e.GetType().Name); } }
|
||||
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);
|
||||
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose();
|
||||
LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable);
|
||||
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved;
|
||||
@@ -280,8 +290,14 @@ namespace game.PlayerDataStorage
|
||||
var pf=shell.AddComponent<PlayFabMgr>();
|
||||
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
|
||||
PF.SetValue(null,pf);
|
||||
var store=container.Resolve<IPlayerSaveStore>();
|
||||
container.RegisterInstance<IPlayerSaveStore>(store);
|
||||
container.Resolve<PlayerData>().playerSaveStore=store;
|
||||
container.Resolve<PlayerFishData>().playerSaveStore=store;
|
||||
container.Resolve<SmallGameData>().playerSaveStore=store;
|
||||
events.playerSaveStore=store;
|
||||
var items=new PlayerItemData(tables);container.RegisterInstance(items);
|
||||
var data=new BattlePassData(tables);
|
||||
var data=new BattlePassData(tables){playerSaveStore=store};
|
||||
data.BattlePassRecord.ID=tables.TbFishingEvent.DataList.First(e=>tables.TbBattlePassMain.GetOrDefault(e.RedirectID)!=null).ID;
|
||||
data.BattlePassRecord.Level=1;
|
||||
data.BattleTask.dic_dailyTasks.Add(tables.TbBattlePassTask.DataList.First(t=>t.Type==cfg.ConditionType.ConsumeEnergy).TaskID,0);
|
||||
|
||||
@@ -131,7 +131,7 @@ namespace game.PlayerDataStorage
|
||||
}
|
||||
internal static string Read(string key)
|
||||
{
|
||||
var result=PlayFabMgr.ReadKV(Capture(),key);
|
||||
var result=KVMgr.ReadKV(Capture(),key);
|
||||
if(!result.Success) throw Stop("Read: "+result.Error);
|
||||
return result.Found==true ? result.Value : null;
|
||||
}
|
||||
@@ -142,7 +142,7 @@ namespace game.PlayerDataStorage
|
||||
CheckRewardWrite(key,value);
|
||||
var valueHash=value==null?null:PlayerKVLoopPermit.Hash(Encoding.UTF8.GetBytes(value));
|
||||
Audit("write-attempt",new{key,remote,valueHash});
|
||||
var result=PlayFabMgr.WriteKV(owner,key,value,remote);
|
||||
var result=KVMgr.WriteKV(owner,key,value,remote);
|
||||
if(!result.Accepted) throw Stop("Write: "+result.StateResult.Error+" uncertain="+result.StateResult.LocalCommitMayHaveSucceeded);
|
||||
if(collecting!=null)
|
||||
{
|
||||
@@ -220,7 +220,7 @@ namespace game.PlayerDataStorage
|
||||
last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets.");
|
||||
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
var flush=await PlayFabMgr.FlushKVAsync(batch.Owner,timeout.Token);
|
||||
var flush=await KVMgr.FlushKVAsync(batch.Owner,timeout.Token);
|
||||
if(!FishingCurrent(action) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
|
||||
throw Stop("Fishing flush: "+flush.TransportError);
|
||||
foreach(var record in batch.Records)
|
||||
@@ -231,7 +231,7 @@ namespace game.PlayerDataStorage
|
||||
if(ticket.OwnerInstanceId!=batch.Owner.OwnerInstanceId || ticket.Epoch!=batch.Owner.Epoch ||
|
||||
ticket.Key!=record.Key || final.OwnerInstanceId!=ticket.OwnerInstanceId || final.Epoch!=ticket.Epoch)
|
||||
throw Stop("Fishing ticket identity mismatch.");
|
||||
var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,ticket,timeout.Token);
|
||||
var result=await KVMgr.WaitForKVTicketAsync(batch.Owner,ticket,timeout.Token);
|
||||
if(!FishingCurrent(action)) throw Stop("Fishing action changed while waiting.");
|
||||
bool explained=result.Status==PlayerKVTicketStatus.Superseded && !ReferenceEquals(ticket,final) && final.Sequence>ticket.Sequence;
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed && !explained) throw Stop("Fishing ticket: "+result.Status);
|
||||
@@ -254,12 +254,12 @@ namespace game.PlayerDataStorage
|
||||
{
|
||||
if(primary?.Ticket==null || !batch.Edits.Contains(primary) || !ReferenceEquals(Capture(),batch.Owner))
|
||||
throw Stop("Missing original equipment ticket.");
|
||||
var flush=await PlayFabMgr.FlushKVAsync(batch.Owner);
|
||||
var flush=await KVMgr.FlushKVAsync(batch.Owner);
|
||||
if(flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Equipment flush: "+flush.TransportError);
|
||||
foreach(var edit in batch.Edits)
|
||||
{
|
||||
if(edit.Ticket==null) continue; // localOnly is audited, never claimed as remotely confirmed.
|
||||
var result=await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket);
|
||||
var result=await KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket);
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status);
|
||||
}
|
||||
if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed.");
|
||||
@@ -270,7 +270,7 @@ namespace game.PlayerDataStorage
|
||||
if(!Ready) return;
|
||||
try
|
||||
{
|
||||
var result=await PlayFabMgr.FlushKVAsync(Capture());
|
||||
var result=await KVMgr.FlushKVAsync(Capture());
|
||||
if(result.Unknown>0 || result.Blocked>0 || result.TransportError!=PlayerKVError.None) throw Stop("Periodic flush: "+result.TransportError);
|
||||
}
|
||||
catch(Exception e) { Fault(e.Message); }
|
||||
@@ -342,14 +342,14 @@ namespace game.PlayerDataStorage
|
||||
Check(batch.Edits.Count==2 && ReferenceEquals(primary,batch.Edits[1]),"all original edits collected");
|
||||
Check(fixture.Sets==0,"write acceptance is local only");
|
||||
await ConfirmEquipAsync(batch,primary);
|
||||
foreach(var edit in batch.Edits) Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed");
|
||||
foreach(var edit in batch.Edits) Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed");
|
||||
Check(fixture.Remote["k"]=="nested" && fixture.Remote["NewPlayerDataDic"]=="whole-save" && Ready && fixture.Faults==0,"complete batch committed");
|
||||
|
||||
await Start(); batch=BeginEquip();
|
||||
var first=Write("k","first",true); Write("k","second",true);
|
||||
primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose();
|
||||
await Reject(ConfirmEquipAsync(batch,primary),"Superseded");
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced");
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced");
|
||||
|
||||
await Start(); batch=BeginEquip();
|
||||
Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose();
|
||||
@@ -362,8 +362,8 @@ namespace game.PlayerDataStorage
|
||||
Check(batch.Edits.Count==2,"later write excluded from synchronous batch");
|
||||
fixture.ReleaseSet.TrySetResult(true); await confirmation; pendingConfirmation=null;
|
||||
Check(!fixture.Remote.ContainsKey("late"),"flush watermark excludes later write");
|
||||
await PlayFabMgr.FlushKVAsync(batch.Owner);
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush");
|
||||
await KVMgr.FlushKVAsync(batch.Owner);
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush");
|
||||
|
||||
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose();
|
||||
fixture.Unauthorized=true;
|
||||
@@ -374,7 +374,7 @@ namespace game.PlayerDataStorage
|
||||
fixture.LoseResponse=true;
|
||||
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
|
||||
Check(fixture.Remote["NewPlayerDataDic"]=="lost-response" && fixture.Sets==1,"unknown remote commit is not replayed");
|
||||
Check((await PlayFabMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved");
|
||||
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved");
|
||||
|
||||
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose();
|
||||
await fixture.Close();
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using game.PlayerDataStorage;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game
|
||||
{
|
||||
@@ -105,6 +110,14 @@ namespace game
|
||||
EAccoutUrl=state.AccountUrls.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
ReplaceKVLinked(state);
|
||||
}
|
||||
void IKVUserLoadReceiver.ResetForKVLoad() => ResetForKVLoad();
|
||||
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
|
||||
=> ReplaceFromKVDecoded(state,epoch,raw);
|
||||
void IKVUserLoadReceiver.DiscardKVIntents() => KVIntents?.Discard();
|
||||
bool IKVUserLifecycle.KVHandoffReady => false;
|
||||
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
|
||||
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
|
||||
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
|
||||
internal virtual void ReplaceKVLinked(PlayerKVLoginState state) { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,11 +63,10 @@ namespace game
|
||||
string DisplayName { get; }
|
||||
string AvatarUrl { get; }
|
||||
Dictionary<string, string> EAccoutUrl { get; }
|
||||
EntityKey entityKey { get; }
|
||||
Task<string> UpdateAvatarUrl(string url);
|
||||
string UserId { get; }
|
||||
string ApiPlayerId { get; }
|
||||
string CustomId { get; }
|
||||
string CustomId { get; set; }
|
||||
uint InternalId { get; }
|
||||
string ContinentCode { get; }
|
||||
//string CountryCode { get; }
|
||||
@@ -99,7 +98,7 @@ namespace game
|
||||
void UpdateProfileLv();
|
||||
}
|
||||
|
||||
public partial class PlayFabUserService : IUserService
|
||||
public partial class PlayFabUserService : IUserService, PlayerDataStorage.IKVUserLoadReceiver, PlayerDataStorage.IKVUserLifecycle
|
||||
{
|
||||
[Inject]
|
||||
public IConfig config { get; set; }
|
||||
@@ -143,7 +142,7 @@ namespace game
|
||||
const string MMTestSubKey = "MMFailParam";
|
||||
public string GetDefaultName(string id)
|
||||
{
|
||||
return $"Angler#{id[0..5]}";
|
||||
return "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
|
||||
}
|
||||
|
||||
public PlayFabUserService() : this(null) { }
|
||||
@@ -180,7 +179,9 @@ namespace game
|
||||
if (generation != logoutGeneration || requestedId != CustomId) return ELoginStep.Api;
|
||||
if (!long.TryParse(requestedId, System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
|
||||
{
|
||||
return ELoginStep.Api;
|
||||
}
|
||||
return await LoginCore(progress, generation);
|
||||
}
|
||||
finally { loginGate.Release(); }
|
||||
|
||||
Reference in New Issue
Block a user