feat(upserver): 接入认证与KV存档并完成限定钓鱼闭环
This commit is contained in:
@@ -77,7 +77,7 @@ public class GConstant
|
||||
|
||||
|
||||
public const string K_FuncUrl = "FuncUrl";
|
||||
public const string V_FuncUrl = "http://192.168.9.101:7071";
|
||||
public const string V_FuncUrl = "http://s101.local:7071";
|
||||
|
||||
public const string K_FuncKey = "FuncKey";
|
||||
public const string V_FuncKey = "testKey";
|
||||
|
||||
@@ -10,6 +10,15 @@ namespace Activity
|
||||
public class ActivitySentryManager
|
||||
{
|
||||
private static Dictionary<(int, int), Type> _activitySentryTypes;
|
||||
private static object _registrationContainer;
|
||||
|
||||
private static void EnsureCurrentContainer()
|
||||
{
|
||||
if (ReferenceEquals(_registrationContainer, GContext.container)) return;
|
||||
_registrationContainer = GContext.container;
|
||||
_activitySentryTypes = null;
|
||||
TypeKeyCache.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 类型键缓存:Sentry 类型 → (EventType, EventSubType)
|
||||
@@ -21,6 +30,7 @@ namespace Activity
|
||||
{
|
||||
get
|
||||
{
|
||||
EnsureCurrentContainer();
|
||||
if (_activitySentryTypes != null) return _activitySentryTypes;
|
||||
InitActivitySentryTypes();
|
||||
|
||||
@@ -42,6 +52,7 @@ namespace Activity
|
||||
}
|
||||
public static void InitActivitySentryType(Type type)
|
||||
{
|
||||
EnsureCurrentContainer();
|
||||
_activitySentryTypes ??= new Dictionary<(int, int), Type>();
|
||||
try
|
||||
{
|
||||
@@ -82,4 +93,4 @@ namespace Activity
|
||||
return TypeKeyCache.GetValueOrDefault(sentryType);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,6 +101,24 @@ public class CampDataMM
|
||||
//是否需要修复
|
||||
public bool IsFix => _isFix && AreaIndex < AreaList.Count;
|
||||
bool _isFix;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVCampState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCamp(raw,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
CampDataDic=new Dictionary<string,int>(StringComparer.Ordinal);
|
||||
_isFix=false; _isOpenSkyscraper="0"; _id=101;
|
||||
PreID=AreaIndex=AreaStep=AreaId=AreaStepCount=TimelineId=0;
|
||||
AreaIcon=null; constructionArea=null; StepInfo=null; AreaStepList=null; IsUpdataArea=false;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCampState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
CampDataDic=state.Values.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
_isFix=state.IsFix; _isOpenSkyscraper=state.Skyscraper;
|
||||
}
|
||||
internal game.PlayerDataStorage.PlayerKVCampState SnapshotForKVValidation() =>
|
||||
new game.PlayerDataStorage.PlayerKVCampState(_isFix,_isOpenSkyscraper,CampDataDic);
|
||||
public void InitData(Dictionary<string, string> userData)
|
||||
{
|
||||
string value;
|
||||
|
||||
@@ -6,6 +6,8 @@ using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using game.Account;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
public interface ICustomServerMgr
|
||||
{
|
||||
@@ -21,6 +23,11 @@ public interface ICustomServerMgr
|
||||
Task<T> EventPartnerRequest<T>(string url, object data = null);
|
||||
Task<string> EventPartnerRequest(string url, object data);
|
||||
CookieContainer Cookie { get; }
|
||||
Uri ApiBaseUri { get; }
|
||||
int SessionVersion { get; }
|
||||
event Action SessionChanged;
|
||||
void UseAccountSession(S101AccountSession session);
|
||||
void ClearAccountSession();
|
||||
}
|
||||
|
||||
public class CustomServerMgr : ICustomServerMgr
|
||||
@@ -31,87 +38,122 @@ public class CustomServerMgr : ICustomServerMgr
|
||||
HttpClient client;
|
||||
|
||||
private CookieContainer _cookie;
|
||||
private readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
public int SessionVersion { get; private set; }
|
||||
public event Action SessionChanged;
|
||||
PlayerKVAccountLease kvLease;
|
||||
bool kvOnly;
|
||||
int pfRequests;
|
||||
internal event Action<int> KVUnauthorized;
|
||||
internal bool IsFreshForKVLoad => pfRequests==0 && kvLease==null;
|
||||
internal void BindKVForValidation(PlayerKVAccountLease lease)
|
||||
{
|
||||
if(lease==null || !lease.IsCurrent || lease.ApiBaseUri!=ApiBaseUri || !IsFreshForKVLoad)
|
||||
throw new InvalidOperationException("Fresh matching KV lease required.");
|
||||
kvOnly=true; SessionVersion++; kvLease=lease; _cookie=null;
|
||||
var previous=client; client=null; previous?.Dispose(); SessionChanged?.Invoke();
|
||||
}
|
||||
internal Task ClearKVForValidation()
|
||||
{
|
||||
SessionVersion++; var previous=kvLease; kvLease=null;
|
||||
var closing=previous?.CloseAsync() ?? Task.CompletedTask;
|
||||
SessionChanged?.Invoke(); return closing;
|
||||
}
|
||||
internal async Task<PlayerKVAccountResponse> SendKVForValidationAsync(string route)
|
||||
{
|
||||
var lease=kvLease; int version=SessionVersion;
|
||||
if(!kvOnly || lease==null) return new PlayerKVAccountResponse(false);
|
||||
var response=await lease.SendAsync(HttpMethod.Get,route);
|
||||
if(version!=SessionVersion || !ReferenceEquals(lease,kvLease) || !response.IsCurrent) return new PlayerKVAccountResponse(false);
|
||||
if(response.StatusCode==401 || response.StatusCode==403)
|
||||
{
|
||||
KVUnauthorized?.Invoke(version);
|
||||
return new PlayerKVAccountResponse(false,response.StatusCode);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
public CookieContainer Cookie => _cookie ?? throw new System.NullReferenceException();
|
||||
public Uri ApiBaseUri { get; }
|
||||
|
||||
public CustomServerMgr(IConfig config)
|
||||
public CustomServerMgr(IConfig config) : this(config, null) { }
|
||||
|
||||
public CustomServerMgr(IConfig config, Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{
|
||||
this.handlerFactory = handlerFactory ?? (cookies => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = cookies, UseCookies = true, AllowAutoRedirect = false
|
||||
});
|
||||
this.config = config;
|
||||
|
||||
var httpClientHandler = new HttpClientHandler();
|
||||
_cookie = new CookieContainer();
|
||||
httpClientHandler.CookieContainer = _cookie;
|
||||
client = new HttpClient(httpClientHandler);
|
||||
|
||||
client.BaseAddress =
|
||||
new System.Uri(config.Get<string>(GConstant.K_Event_API_URL,
|
||||
"http://192.168.9.101:5292/"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
var url = config.Get<string>(GConstant.K_Event_API_URL, S101AccountSession.DefaultApiUrl);
|
||||
if (string.IsNullOrWhiteSpace(url)) url = S101AccountSession.DefaultApiUrl;
|
||||
ApiBaseUri = new Uri(url.TrimEnd('/') + "/");
|
||||
ClearAccountSession();
|
||||
}
|
||||
|
||||
public async Task<string> CustomServerPost(string url, string bodyString,
|
||||
string contentType = "application/json")
|
||||
public void UseAccountSession(S101AccountSession session)
|
||||
{
|
||||
if(kvOnly) throw new InvalidOperationException("Controlled container cannot switch to PlayFab.");
|
||||
if (session == null || !session.IsAuthenticated || session.ApiBaseUri != ApiBaseUri)
|
||||
throw new ArgumentException("An authenticated session for this API is required.", nameof(session));
|
||||
ReplaceTransport(session.Cookies);
|
||||
}
|
||||
|
||||
public void ClearAccountSession()
|
||||
{
|
||||
if(kvOnly) { PlayerKVShutdownRegistry.Process.Register(ClearKVForValidation()); return; }
|
||||
ReplaceTransport(new CookieContainer());
|
||||
}
|
||||
|
||||
private void ReplaceTransport(CookieContainer cookies)
|
||||
{
|
||||
SessionVersion++;
|
||||
var previous = client;
|
||||
_cookie = cookies;
|
||||
client = new HttpClient(handlerFactory(cookies)) { BaseAddress = ApiBaseUri, Timeout = TimeSpan.FromSeconds(15) };
|
||||
previous?.Dispose();
|
||||
SessionChanged?.Invoke();
|
||||
}
|
||||
|
||||
public Task<string> CustomServerPost(string url, string bodyString,
|
||||
string contentType = "application/json") => SendAsync(HttpMethod.Post, url, bodyString, contentType);
|
||||
|
||||
public Task<string> GetStringAsync(string url) => SendAsync(HttpMethod.Get, url, null, null);
|
||||
|
||||
// Unity callers use the main thread. Capture a fixed transport before the first await.
|
||||
private async Task<string> SendAsync(HttpMethod method, string url, string body, string contentType)
|
||||
{
|
||||
if(kvOnly) throw new InvalidOperationException("Only controlled validation routes are enabled.");
|
||||
pfRequests++;
|
||||
var transport = client;
|
||||
var version = SessionVersion;
|
||||
try
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
Debug.Log("client.BaseAddress.AbsoluteUri:" + client.BaseAddress.AbsoluteUri);
|
||||
Debug.Log("CustomServerPost url:" + url);
|
||||
Debug.Log("CustomServerPost bodyString:" + bodyString);
|
||||
#endif
|
||||
var content = new StringContent(bodyString, Encoding.UTF8, "application/json");
|
||||
var response = await client.PostAsync(url, content);
|
||||
string result = await response.Content.ReadAsStringAsync();
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.OK)
|
||||
using (var request = new HttpRequestMessage(method, url))
|
||||
{
|
||||
//GameDebug.Log("CustomServerPost result:" + result.ToString());
|
||||
return result;
|
||||
}
|
||||
else if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized || response.StatusCode == System.Net.HttpStatusCode.Forbidden)
|
||||
{
|
||||
GContext.Publish(new game.EvtAPISvrUnauthorized());
|
||||
return null;
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError(response.StatusCode.ToString() + "+++CustomServerPost error:" +
|
||||
result.ToString() + "+++CustomServerPost url:" + url + ":" +
|
||||
bodyString);
|
||||
return null;
|
||||
if (body != null) request.Content = new StringContent(body, Encoding.UTF8, contentType);
|
||||
using (var response = await transport.SendAsync(request))
|
||||
{
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
if (version != SessionVersion) return null;
|
||||
if (response.StatusCode == HttpStatusCode.OK) return result;
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized || response.StatusCode == HttpStatusCode.Forbidden)
|
||||
GContext.Publish(new game.EvtAPISvrUnauthorized { SessionVersion = version });
|
||||
else
|
||||
Debug.LogError($"API request failed: HTTP {(int)response.StatusCode}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError("CustomServerPost error:" + e.Message + "---CustomServerPost url:" +
|
||||
url + ":" + bodyString + "\n" + e.StackTrace);
|
||||
if (version == SessionVersion) Debug.LogError($"API request failed: {e.GetType().Name}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GetStringAsync(string url)
|
||||
{
|
||||
var response = await client.GetAsync(url);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.OK)
|
||||
{
|
||||
return await response.Content.ReadAsStringAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
string result = await response.Content.ReadAsStringAsync();
|
||||
Debug.LogError(response.StatusCode.ToString() + "+++GetStringAsync error:" +
|
||||
result.ToString() + "+++GetStringAsync url:" + url);
|
||||
}
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.Unauthorized)
|
||||
{
|
||||
GContext.Publish(new game.EvtAPISvrUnauthorized());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public async Task<string> AccountRequest(string url, object data)
|
||||
{
|
||||
return await CustomServerPost($"account/{url}",
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
public class PlayFabMgr : MonoBehaviour
|
||||
{
|
||||
@@ -25,6 +26,9 @@ public class PlayFabMgr : MonoBehaviour
|
||||
private CancellationTokenSource tokenSource;
|
||||
|
||||
static PlayFabMgr _instance = null;
|
||||
// An inactive, never-awakened component proves that no timer or PF send has ever been started.
|
||||
internal bool IsUnstartedForKVLoad => tokenSource==null && dataBuffer==null && swapData==null && localData==null && lastUpdateTime==null && _instance!=this;
|
||||
internal static bool HasLiveInstanceForKVLoad => _instance!=null;
|
||||
|
||||
public static PlayFabMgr Instance
|
||||
{
|
||||
@@ -41,6 +45,10 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
public Dictionary<string, string> CompareDatas(Dictionary<string, UserDataRecord> remoteDatas, string userId)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("PF archive comparison is disabled in KV loop.");
|
||||
#endif
|
||||
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Compare);
|
||||
Last_Update_Time_Key = $"{Last_Update_Time_Key_Prefix}_{userId}";
|
||||
Local_Data_Key = $"{Local_Data_Key_prefix}_{userId}";
|
||||
var lastUpdateTimeStr = PlayerPrefs.GetString(Last_Update_Time_Key, string.Empty);
|
||||
@@ -114,12 +122,26 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
|
||||
_instance = this;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) return;
|
||||
#endif
|
||||
InitBuffer();
|
||||
tokenSource = new CancellationTokenSource();
|
||||
UpdateUserDataValue(tokenSource.Token);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
internal void StartKVGameplayLoopSync()
|
||||
{
|
||||
if(!PlayerKVGameplayLoop.Ready || tokenSource!=null) return;
|
||||
tokenSource=new CancellationTokenSource();
|
||||
UpdateUserDataValue(tokenSource.Token);
|
||||
}
|
||||
internal void StopKVGameplayLoopSync()
|
||||
{ tokenSource?.Cancel(); tokenSource?.Dispose(); tokenSource=null; }
|
||||
#endif
|
||||
void InitBuffer()
|
||||
{
|
||||
dataBuffer = new LinkedList<Dictionary<string, string>>();
|
||||
@@ -132,6 +154,15 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
void OnApplicationPause(bool is_pause)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
if(is_pause) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.FlushObservedAsync(); }
|
||||
else StartKVGameplayLoopSync();
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
|
||||
if (is_pause)
|
||||
{
|
||||
tokenSource.Cancel();
|
||||
@@ -147,6 +178,10 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
void OnApplicationQuit()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
|
||||
#endif
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
|
||||
tokenSource.Cancel();
|
||||
tokenSource = null;
|
||||
SyncData();
|
||||
@@ -154,13 +189,19 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
void OnDestroy()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) { StopKVGameplayLoopSync(); _=PlayerKVGameplayLoop.CloseAsync(); return; }
|
||||
#endif
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
|
||||
tokenSource?.Cancel();
|
||||
tokenSource = null;
|
||||
if(!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.PrefsSave)) PlayerPrefs.Save();
|
||||
SyncData();
|
||||
}
|
||||
|
||||
private async void UpdateUserDataValue(CancellationToken token)
|
||||
{
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Lifecycle)) return;
|
||||
try
|
||||
{
|
||||
while (!token.IsCancellationRequested)
|
||||
@@ -174,6 +215,10 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
public void UpdateUserDataValue(string key, string data, bool remoteStore = true)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) { PlayerKVGameplayLoop.Write(key,data,remoteStore); return; }
|
||||
#endif
|
||||
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.Update);
|
||||
if (remoteStore)
|
||||
{
|
||||
var cbuff = dataBuffer.First;
|
||||
@@ -203,6 +248,10 @@ public class PlayFabMgr : MonoBehaviour
|
||||
/// <returns></returns>
|
||||
public string GetLocalData(string key)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) return PlayerKVGameplayLoop.Read(key);
|
||||
#endif
|
||||
PlayerKVGameLoadGuard.RequireLegacyAllowed(PlayerKVGuardPoint.ReadLocal);
|
||||
if (localData.ContainsKey(key))
|
||||
{
|
||||
return localData[key];
|
||||
@@ -215,6 +264,12 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
private void SyncData()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) { _=PlayerKVGameplayLoop.FlushObservedAsync(); return; }
|
||||
#endif
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync)) return;
|
||||
// Logout clears credentials before RootCtx is destroyed. Keep pending data local.
|
||||
if (!PlayFabClientAPI.IsClientLoggedIn()) return;
|
||||
var buf = dataBuffer.First;
|
||||
if (buf.Value.Count == 0)
|
||||
return;
|
||||
@@ -267,6 +322,10 @@ public class PlayFabMgr : MonoBehaviour
|
||||
|
||||
public async Task<bool> SyncDataImmidiate(string key, string data)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled) throw PlayerKVGameplayLoop.Stop("Immediate/consuming saves are outside the equipment loop.");
|
||||
#endif
|
||||
if(PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate)) return false;
|
||||
foreach (var buff in dataBuffer)
|
||||
{
|
||||
if (buff.ContainsKey(key))
|
||||
@@ -293,4 +352,52 @@ public class PlayFabMgr : MonoBehaviour
|
||||
);
|
||||
return await taskSource.Task;
|
||||
}
|
||||
|
||||
// BEGIN KV explicit fixed-handle access
|
||||
internal static PlayerKVKeyReadResult ReadKV(PlayerKVGameSessionHandle handle, string key)
|
||||
{
|
||||
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal))
|
||||
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle);
|
||||
if (handle == null || !KVSnapshot.ValidKey(key))
|
||||
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.InvalidInput, handle);
|
||||
var read = handle.ReadLocalSnapshot();
|
||||
if (!read.Success)
|
||||
return new PlayerKVKeyReadResult(read.Error == PlayerKVLocalReadError.StaleOrClosing
|
||||
? PlayerKVKeyReadError.StaleOrClosing : PlayerKVKeyReadError.NotReady, handle);
|
||||
var found = read.Data.TryGetValue(key, out var value);
|
||||
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.None, handle, found, value);
|
||||
}
|
||||
|
||||
internal static PlayerKVEditResult WriteKV(PlayerKVGameSessionHandle handle,
|
||||
string key, string value, bool remoteStore = true)
|
||||
{
|
||||
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update))
|
||||
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
|
||||
if (handle == null)
|
||||
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation));
|
||||
return handle.Update(key, value, remoteStore);
|
||||
}
|
||||
|
||||
internal static Task<PlayerKVFlushResult> FlushKVAsync(PlayerKVGameSessionHandle handle,
|
||||
CancellationToken waitToken = default)
|
||||
{
|
||||
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync))
|
||||
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled,
|
||||
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status));
|
||||
if (handle == null)
|
||||
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
|
||||
return handle.FlushAsync(waitToken);
|
||||
}
|
||||
|
||||
internal static Task<PlayerKVTicketResult> WaitForKVTicketAsync(PlayerKVGameSessionHandle handle,
|
||||
PlayerKVSyncTicket ticket, CancellationToken waitToken = default)
|
||||
{
|
||||
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Sync))
|
||||
return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled,
|
||||
handle == null ? PlayerKVSyncStatus.NotLoaded : handle.Status));
|
||||
if (handle == null)
|
||||
return Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
|
||||
return handle.WaitForTicketAsync(ticket, waitToken);
|
||||
}
|
||||
// END KV explicit fixed-handle access
|
||||
}
|
||||
|
||||
@@ -198,54 +198,69 @@ namespace Game
|
||||
}
|
||||
|
||||
protected CompositeDisposable disposables;
|
||||
private int initializationVersion;
|
||||
public async Task Init(System.Action<float> progress)
|
||||
{
|
||||
//playerData = GContext.container.Resolve<PlayerData>();
|
||||
_tables = GContext.container.Resolve<cfg.Tables>();
|
||||
//var service = (SoundService)soundService;
|
||||
await soundService.InitAsync();
|
||||
|
||||
if (residentClip != null || disposables != null) Release();
|
||||
var version = ++initializationVersion;
|
||||
var container = GContext.container;
|
||||
_tables = container.Resolve<cfg.Tables>();
|
||||
var opMap = new Dictionary<string, AsyncOperationHandle<AudioClip>>();
|
||||
|
||||
foreach (var addr in residentFish)
|
||||
CompositeDisposable pendingSubscriptions = null;
|
||||
bool transferred = false;
|
||||
try
|
||||
{
|
||||
opMap[addr] = Addressables.LoadAssetAsync<AudioClip>(addr);
|
||||
}
|
||||
|
||||
var tasks = opMap.Values.Select(_ => _.Task);
|
||||
var total = (float)tasks.Count();
|
||||
|
||||
while (true)
|
||||
{
|
||||
var completed = tasks.Count(_ => _.IsCompleted);
|
||||
if (completed == total)
|
||||
await soundService.InitAsync();
|
||||
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
|
||||
foreach (var addr in residentFish)
|
||||
{
|
||||
progress.Invoke(1f);
|
||||
break;
|
||||
if (!opMap.ContainsKey(addr)) opMap.Add(addr, Addressables.LoadAssetAsync<AudioClip>(addr));
|
||||
}
|
||||
else
|
||||
var tasks = opMap.Values.Select(_ => _.Task).ToArray();
|
||||
var total = (float)tasks.Length;
|
||||
while (true)
|
||||
{
|
||||
var completed = tasks.Count(_ => _.IsCompleted);
|
||||
if (completed == total)
|
||||
{
|
||||
progress.Invoke(1f);
|
||||
break;
|
||||
}
|
||||
progress.Invoke(completed / total);
|
||||
await Awaiters.NextFrame;
|
||||
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
|
||||
}
|
||||
if (version != initializationVersion || !ReferenceEquals(container, GContext.container)) return;
|
||||
var loadedClips = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result);
|
||||
pendingSubscriptions = new CompositeDisposable();
|
||||
GContext.OnEvent<EventUISound>().Subscribe(PlaySound).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<EventRewardSound>().Subscribe(PlayRewardSound).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<EventFishingSound>().Subscribe(PlayFishingSound).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<EventBGMSound>().Subscribe(PlayMusic).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<FadeOutEvent>().Subscribe(FadeOutEvent).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<AssetReferenceAudioClip>().Subscribe(PlaySoundByReference).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<AssetReferenceAudioVoiceClip>().Subscribe(PlayVoiceSoundByReference).AddTo(pendingSubscriptions);
|
||||
|
||||
GContext.OnEvent<ChangeRodDataEvent>().Subscribe(OnChangeRodData).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<SoundMuteEvent>().Subscribe(OnSoundMute).AddTo(pendingSubscriptions);
|
||||
GContext.OnEvent<OnSoundStateEvent>().Subscribe(OnSoundStateEvent).AddTo(pendingSubscriptions);
|
||||
soundService.BGMVol = container.Resolve<ISettingService>().Music ? 80 : 0;
|
||||
soundService.SFXVol = container.Resolve<ISettingService>().Sound ? 80 : 0;
|
||||
soundService.VoiceVol = container.Resolve<ISettingService>().Sound ? 80 : 0;
|
||||
residentClip = loadedClips;
|
||||
disposables = pendingSubscriptions;
|
||||
transferred = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!transferred)
|
||||
{
|
||||
pendingSubscriptions?.Dispose();
|
||||
if (version == initializationVersion) Release();
|
||||
foreach (var handle in opMap.Values)
|
||||
if (handle.IsValid()) Addressables.Release(handle);
|
||||
}
|
||||
await Awaiters.NextFrame;
|
||||
}
|
||||
|
||||
residentClip = opMap.ToDictionary(_ => _.Key, _ => _.Value.Result);
|
||||
disposables = new CompositeDisposable();
|
||||
GContext.OnEvent<EventUISound>().Subscribe(PlaySound).AddTo(disposables);
|
||||
GContext.OnEvent<EventRewardSound>().Subscribe(PlayRewardSound).AddTo(disposables);
|
||||
GContext.OnEvent<EventFishingSound>().Subscribe(PlayFishingSound).AddTo(disposables);
|
||||
GContext.OnEvent<EventBGMSound>().Subscribe(PlayMusic).AddTo(disposables);
|
||||
GContext.OnEvent<FadeOutEvent>().Subscribe(FadeOutEvent).AddTo(disposables);
|
||||
GContext.OnEvent<AssetReferenceAudioClip>().Subscribe(PlaySoundByReference).AddTo(disposables);
|
||||
GContext.OnEvent<AssetReferenceAudioVoiceClip>().Subscribe(PlayVoiceSoundByReference).AddTo(disposables);
|
||||
|
||||
GContext.OnEvent<ChangeRodDataEvent>().Subscribe(OnChangeRodData).AddTo(disposables);
|
||||
GContext.OnEvent<SoundMuteEvent>().Subscribe(OnSoundMute).AddTo(disposables);
|
||||
GContext.OnEvent<OnSoundStateEvent>().Subscribe(OnSoundStateEvent).AddTo(disposables);
|
||||
soundService.BGMVol = GContext.container.Resolve<ISettingService>().Music ? 80 : 0;
|
||||
soundService.SFXVol = GContext.container.Resolve<ISettingService>().Sound ? 80 : 0;
|
||||
soundService.VoiceVol = GContext.container.Resolve<ISettingService>().Sound ? 80 : 0;
|
||||
}
|
||||
void OnSoundStateEvent(OnSoundStateEvent onSoundStateEvent)
|
||||
{
|
||||
@@ -757,6 +772,7 @@ namespace Game
|
||||
}
|
||||
public void Release()
|
||||
{
|
||||
initializationVersion++;
|
||||
if (disposables != null)
|
||||
{
|
||||
disposables.Dispose();
|
||||
|
||||
@@ -32,6 +32,31 @@ public class AchievementDataManager : IDisposable
|
||||
{
|
||||
_tables = tables;
|
||||
}
|
||||
internal static game.PlayerDataStorage.PlayerKVAchievementState DecodeForKVLoad(
|
||||
IReadOnlyDictionary<string, string> raw, IReadOnlyDictionary<string, string> modern,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
|
||||
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeAchievements(raw, modern, snapshot, errors);
|
||||
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
_achievementDatas = new Dictionary<ConditionType, AchievementData>();
|
||||
}
|
||||
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVAchievementState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
var replacement = new Dictionary<ConditionType, AchievementData>();
|
||||
foreach (var entry in state.Values)
|
||||
replacement.Add((ConditionType)entry.Key, new AchievementData
|
||||
{ Id = entry.Value.Id, Target = entry.Value.Target, day = entry.Value.Day });
|
||||
_achievementDatas = replacement;
|
||||
}
|
||||
|
||||
internal bool IsEmptyForKVValidation => _achievementDatas.Count == 0;
|
||||
internal AchievementData FindForKVValidation(ConditionType key)
|
||||
=> _achievementDatas.TryGetValue(key, out var value)
|
||||
? new AchievementData { Id = value.Id, Target = value.Target, day = value.day } : null;
|
||||
|
||||
public void LoadAchievementData(string value)
|
||||
{
|
||||
_achievementDatas = Newtonsoft.Json.JsonConvert.DeserializeObject<Dictionary<ConditionType, AchievementData>>(value);
|
||||
|
||||
@@ -13,6 +13,62 @@ public class BargainPackData
|
||||
private readonly TbSpecialPack _spp = GContext.container.Resolve<Tables>().TbSpecialPack;
|
||||
private GeneralEventPackData _data;
|
||||
|
||||
private bool _kvLoadPresent;
|
||||
#if UNITY_EDITOR
|
||||
internal bool SelectionsAreIndependentForKVValidation()
|
||||
{
|
||||
var source = new List<int> { 7, 9 };
|
||||
var state = new game.PlayerDataStorage.PlayerKVBargainState(true,0,_data.redirectID,0,0,0,0,0,0,0,0,0,false,false,0,source);
|
||||
ReplaceFromKVDecoded(state);
|
||||
source.Clear();
|
||||
if (_data.selections.Count != 2 || ReferenceEquals(_data.selections, source) || ReferenceEquals(_data.selections, state.selections)) return false;
|
||||
var previous = _data.selections;
|
||||
_data.selections[0] = 10;
|
||||
if (state.selections[0] != 7) return false;
|
||||
ResetForKVLoad();
|
||||
return _data.selections.Count == 0 && !ReferenceEquals(previous, _data.selections) && !_kvLoadPresent;
|
||||
}
|
||||
#endif
|
||||
internal bool KVLoadPresent => _kvLoadPresent;
|
||||
internal static game.PlayerDataStorage.PlayerKVBargainState DecodeForKVLoad(
|
||||
IReadOnlyDictionary<string, string> raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBargain(raw, snapshot, errors);
|
||||
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
_kvLoadPresent = false;
|
||||
_data = default(GeneralEventPackData);
|
||||
_data.selections = new List<int>();
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBargainState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
if (!state.IsPresent) { ResetForKVLoad(); return; }
|
||||
var replacement = new GeneralEventPackData
|
||||
{
|
||||
currentEventID = state.currentEventID,
|
||||
redirectID = state.redirectID,
|
||||
VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate,
|
||||
purchaseCount = state.purchaseCount,
|
||||
savingProgress = state.savingProgress,
|
||||
discountLvl = state.discountLvl,
|
||||
visualProgress = state.visualProgress,
|
||||
visualDiscountLvl = state.visualDiscountLvl,
|
||||
rodID = state.rodID,
|
||||
lastPackIDBought = state.lastPackIDBought,
|
||||
packID = state.packID,
|
||||
doNeedTrigger = state.doNeedTrigger,
|
||||
isTriggered = state.isTriggered,
|
||||
inflation = state.inflation,
|
||||
selections = state.selections == null ? null : new List<int>(state.selections),
|
||||
};
|
||||
_data = replacement;
|
||||
_kvLoadPresent = true;
|
||||
}
|
||||
internal game.PlayerDataStorage.PlayerKVBargainState SnapshotForKVValidation() =>
|
||||
new game.PlayerDataStorage.PlayerKVBargainState(_kvLoadPresent, _data.currentEventID, _data.redirectID, _data.VIPLvlWhenEventActivate, _data.purchaseCount, _data.savingProgress, _data.discountLvl, _data.visualProgress, _data.visualDiscountLvl, _data.rodID, _data.lastPackIDBought, _data.packID, _data.doNeedTrigger, _data.isTriggered, _data.inflation, _data.selections);
|
||||
|
||||
public int CurrentEventID => _data.currentEventID;
|
||||
|
||||
public FishingEvent CurrentEvent => _tables.TbFishingEvent[_data.currentEventID];
|
||||
@@ -25,10 +81,11 @@ public class BargainPackData
|
||||
{
|
||||
get
|
||||
{
|
||||
if (CurrentEventID == 0) return false;
|
||||
var fishingEvent = _tables.TbFishingEvent.GetOrDefault(CurrentEventID);
|
||||
if (!(fishingEvent?.TimeDefinition is LimitedTime time)) return false;
|
||||
|
||||
var et = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).EndTime);
|
||||
var st = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).StartTime);
|
||||
var et = DateTime.Parse(time.EndTime);
|
||||
var st = DateTime.Parse(time.StartTime);
|
||||
return ZZTimeHelper.UtcNow() >= st && ZZTimeHelper.UtcNow() < et;
|
||||
}
|
||||
}
|
||||
@@ -259,4 +316,4 @@ public class BargainPackProgressEvent
|
||||
{
|
||||
public int type; //0: 初始化 1:增加动画
|
||||
public int addProgress;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,29 @@ public class BattlePassData
|
||||
public BattlePassRecord BattlePassRecord;
|
||||
public BattleTask BattleTask;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVBattlePassState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBattlePass(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
BattlePassRecord=new BattlePassRecord();
|
||||
BattleTask=new BattleTask();
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBattlePassState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
var record=new BattlePassRecord {
|
||||
ID=state.Record.ID,Level=state.Record.Level,Exp=state.Record.Exp,BuyVipToastIndex=state.Record.BuyVipToastIndex,
|
||||
IsUnlockVip=state.Record.IsUnlockVip,NormalRewardGet=new List<int>(state.Record.Normal),VipRewardGet=new List<int>(state.Record.Vip),
|
||||
LastDropIDs=new List<int>(state.Record.LastDrops),InflationRate=state.Record.Inflation
|
||||
};
|
||||
var task=new BattleTask {
|
||||
dic_dailyTasks=state.Task.Daily.ToDictionary(p=>p.Key,p=>p.Value),dic_weeklyTasks=state.Task.Weekly.ToDictionary(p=>p.Key,p=>p.Value),
|
||||
loginDays=new List<int>(state.Task.LoginDays),DailyTaskEndTime=state.Task.DailyEnd,WeeklyTaskEndTime=state.Task.WeeklyEnd
|
||||
};
|
||||
BattlePassRecord=record; BattleTask=task;
|
||||
}
|
||||
|
||||
private Tables _tables;
|
||||
|
||||
|
||||
@@ -136,20 +159,16 @@ public class BattlePassData
|
||||
|
||||
public void RefreshDaiyTask()
|
||||
{
|
||||
var bpMain = _tables.TbBattlePassMain.GetOrDefault(_tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID).RedirectID);
|
||||
if (bpMain != null)
|
||||
var fishingEvent = _tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID);
|
||||
if (fishingEvent == null) return;
|
||||
var bpMain = _tables.TbBattlePassMain.GetOrDefault(fishingEvent.RedirectID);
|
||||
if (bpMain == null) return;
|
||||
BattleTask.DailyTaskEndTime = ConvertTools.GetDateTimeYMD(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1);
|
||||
var dailyTasks = bpMain.DailyTaskList;
|
||||
BattleTask.dic_dailyTasks.Clear();
|
||||
foreach (var task in dailyTasks)
|
||||
{
|
||||
BattleTask.DailyTaskEndTime = ConvertTools.GetDateTimeYMD(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1);
|
||||
var dailyTasks = bpMain.DailyTaskList;
|
||||
BattleTask.dic_dailyTasks.Clear();
|
||||
foreach (var task in dailyTasks)
|
||||
{
|
||||
BattleTask.dic_dailyTasks.Add(task, 0);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
BattleTask = new BattleTask();
|
||||
BattleTask.dic_dailyTasks.Add(task, 0);
|
||||
}
|
||||
GContext.Publish(new ConditionTypeEvent { type = ConditionType.LoginDays, count = 1 });
|
||||
SaveBattleTask();
|
||||
@@ -157,23 +176,17 @@ public class BattlePassData
|
||||
|
||||
public void RefreshWeeklyTask()
|
||||
{
|
||||
|
||||
var bpMain = _tables.TbBattlePassMain.GetOrDefault(_tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID).RedirectID);
|
||||
if (bpMain != null)
|
||||
var fishingEvent = _tables.TbFishingEvent.GetOrDefault(BattlePassRecord.ID);
|
||||
if (fishingEvent == null) return;
|
||||
var bpMain = _tables.TbBattlePassMain.GetOrDefault(fishingEvent.RedirectID);
|
||||
if (bpMain == null) return;
|
||||
var weeklyTasks = bpMain.WeeklyTaskList;
|
||||
BattleTask.dic_weeklyTasks.Clear();
|
||||
foreach (var task in weeklyTasks)
|
||||
{
|
||||
var weeklyTasks = bpMain.WeeklyTaskList;
|
||||
BattleTask.dic_weeklyTasks.Clear();
|
||||
foreach (var task in weeklyTasks)
|
||||
{
|
||||
BattleTask.dic_weeklyTasks.Add(task, 0);
|
||||
}
|
||||
BattleTask.WeeklyTaskEndTime = ConvertTools.GetDateTimeSunDay(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1);
|
||||
}
|
||||
//当前没有战令了
|
||||
else
|
||||
{
|
||||
BattleTask = new BattleTask();
|
||||
BattleTask.dic_weeklyTasks.Add(task, 0);
|
||||
}
|
||||
BattleTask.WeeklyTaskEndTime = ConvertTools.GetDateTimeSunDay(ZZTimeHelper.UtcNow().UtcNowOffset()).AddDays(1);
|
||||
BattlePassRecord.InflationRate = GContext.container.Resolve<PlayerData>().InflationRate;
|
||||
|
||||
GContext.Publish(new ConditionTypeEvent { type = ConditionType.LoginDays, count = 1 });
|
||||
@@ -207,7 +220,7 @@ public class BattlePassDataProvider : IDisposable
|
||||
private Tables _tables;
|
||||
private Dictionary<int, BattlePassLevel> dic_BPLevel;
|
||||
|
||||
public bool IsActivating => Data.BattlePassRecord.ID != 0;
|
||||
public bool IsActivating => Data.BattlePassRecord.ID != 0 && curPassMain != null;
|
||||
public bool UILockVip = false;
|
||||
public bool IsUnlockVip => Data.BattlePassRecord.IsUnlockVip == 1;
|
||||
|
||||
@@ -249,6 +262,7 @@ public class BattlePassDataProvider : IDisposable
|
||||
|
||||
public void CheckIfRefreshBattblePass()
|
||||
{
|
||||
if (!IsActivating) return;
|
||||
DateTime endTime = GContext.container.Resolve<FishingEventData>().GetEventEndTime(Data.BattlePassRecord.ID);
|
||||
|
||||
if (ZZTimeHelper.UtcNow().UtcNowOffset() >= endTime && IsActivating)
|
||||
|
||||
@@ -16,6 +16,39 @@ public class BuffDataCenter
|
||||
Dictionary<int, FishBuffTimeData> globalBuffDataList = new Dictionary<int, FishBuffTimeData>();
|
||||
FishBuffTimeData fishDailyBuff = null;
|
||||
public List<FishBuffTimeData> showFishBuffIds = new List<FishBuffTimeData>();
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVBuffState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeBuffs(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
mapBuffDataList=new Dictionary<int,FishBuffTimeData>();
|
||||
targetBuffDataList=new List<FishBuffTimeData>();
|
||||
globalBuffDataList=new Dictionary<int,FishBuffTimeData>();
|
||||
showFishBuffIds=new List<FishBuffTimeData>(); fishDailyBuff=null;
|
||||
}
|
||||
static FishBuffTimeData MaterializeKVBuff(game.PlayerDataStorage.PlayerKVBuffValue value) => value==null ? null : new FishBuffTimeData
|
||||
{ SortID=value.SortID,sourceID=value.sourceID,buffID=value.buffID,isEnd=value.isEnd,buffEndTime=value.buffEndTime,isNew=false,addDown=0 };
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVBuffState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
var map=new Dictionary<int,FishBuffTimeData>(); var global=new Dictionary<int,FishBuffTimeData>(); var target=new List<FishBuffTimeData>();
|
||||
foreach(var item in state.Map) map.Add(item.Key,MaterializeKVBuff(item.Value));
|
||||
foreach(var item in state.Global) global.Add(item.Key,MaterializeKVBuff(item.Value));
|
||||
foreach(var item in state.Target) target.Add(MaterializeKVBuff(item));
|
||||
mapBuffDataList=map; globalBuffDataList=global; targetBuffDataList=target; fishDailyBuff=MaterializeKVBuff(state.Daily);
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal FishBuffTimeData BuffForKVValidation(int kind,int key)
|
||||
{
|
||||
if(kind==0) return mapBuffDataList.TryGetValue(key,out var value) ? value : null;
|
||||
if(kind==1) return targetBuffDataList.Count>key ? targetBuffDataList[key] : null;
|
||||
if(kind==2) return fishDailyBuff;
|
||||
return globalBuffDataList.TryGetValue(key,out var global) ? global : null;
|
||||
}
|
||||
internal bool IsEmptyForKVValidation => mapBuffDataList.Count==0 && targetBuffDataList.Count==0 &&
|
||||
globalBuffDataList.Count==0 && showFishBuffIds.Count==0 && fishDailyBuff==null;
|
||||
#endif
|
||||
public void InitData(Dictionary<string, string> userDatas)
|
||||
{
|
||||
if (userDatas.TryGetValue("BuffDataList", out string buffDataStr))
|
||||
@@ -558,4 +591,4 @@ public class AddGlobalBuffEvent
|
||||
{
|
||||
this.buffID = buffID;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,46 @@ namespace GameCore
|
||||
//待释放
|
||||
IDisposable disposable;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVEventBaseState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
|
||||
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventBase(raw,modern,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
limitedTimeEventDic=new Dictionary<int,LimitedTimeEvent>(); fixedTimeEventDic=new Dictionary<int,FixedTimeEvent>();
|
||||
ETDropAfterDropDic=new Dictionary<int,int>(); RankTargetCount=new Dictionary<int,int>(); WheelLevelCount=new Dictionary<int,int>();
|
||||
TurntableCoupon=_tables.TbGlobalConfig.InitWheelTicket; WheelOperateCount=0; AllWheelOperateCount=0; freecount=0; PVPToken=0;
|
||||
collectingTargetEventID=-1; virtualCollectingTargetEventID=-1; NewPlayerEvent=default;
|
||||
collectingTargetInit=null; collectingTargetInitTokenID=0; collectingTargetInitTaskID=0;
|
||||
TargetList=null; collectingTarget=null; preCollectingTarget=null; targetItemDatas=null; preRankTarget=null; rankItemDatas=null;
|
||||
rankEvent=null; rankInit=null; rankTarget=null; turntableEvent=null; wheelInit=null; wheelLevel=null;
|
||||
SystemOpen=new Dictionary<int,bool>(); _unlocked=new HashSet<int>(); _isTargetGuidance=true;
|
||||
IsJoinUsPanelOpen=false; collectingTargetResoure=true; IsAquariumOpen=false; IsOpenDrop=false; OpenAquariumID=0;
|
||||
requiredCount=0; requiredRankCount=0; curActivateItemId=0;
|
||||
duelData=null; rankTierChangeState=0; curSolomain=null; Pack1A1Data=null; Pack1A2Data=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventBaseState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
limitedTimeEventDic=state.Limited.ToDictionary(p=>p.Key,p=>new LimitedTimeEvent { id=p.Value.Id,duration=p.Value.Duration,startTime=p.Value.StartTime });
|
||||
fixedTimeEventDic=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime });
|
||||
ETDropAfterDropDic=state.Target.ToDictionary(p=>p.Key,p=>p.Value); RankTargetCount=state.Rank.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
WheelLevelCount=state.Wheel.ToDictionary(p=>p.Key,p=>p.Value); TurntableCoupon=state.Coupon;
|
||||
WheelOperateCount=state.Operate; AllWheelOperateCount=state.AllOperate; freecount=state.Free; PVPToken=state.PvpToken;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal game.PlayerDataStorage.PlayerKVEventBaseState SnapshotForKVValidation() => new game.PlayerDataStorage.PlayerKVEventBaseState(
|
||||
limitedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVLimitedValue(p.Value.id,p.Value.duration,p.Value.startTime)),
|
||||
fixedTimeEventDic.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVFixedTimeValue(p.Value.id,p.Value.endTime)),
|
||||
ETDropAfterDropDic,RankTargetCount,WheelLevelCount,TurntableCoupon,WheelOperateCount,AllWheelOperateCount,freecount,PVPToken);
|
||||
internal void MutateCollectionsForKVValidation()
|
||||
{ limitedTimeEventDic.Values.First().duration=99; fixedTimeEventDic.Values.First().id=99; ETDropAfterDropDic.Clear(); RankTargetCount.Clear(); WheelLevelCount.Clear(); }
|
||||
internal bool IsInactiveForKVValidation => collectingTargetEventID==-1 && virtualCollectingTargetEventID==-1 && NewPlayerEvent==default &&
|
||||
collectingTargetInit==null && collectingTargetInitTokenID==0 && collectingTargetInitTaskID==0 && TargetList==null && collectingTarget==null &&
|
||||
preCollectingTarget==null && targetItemDatas==null && preRankTarget==null && rankItemDatas==null && rankEvent==null && rankInit==null && rankTarget==null &&
|
||||
turntableEvent==null && wheelInit==null && wheelLevel==null && SystemOpen.Count==0 && _unlocked.Count==0 && duelData==null && curSolomain==null &&
|
||||
Pack1A1Data==null && Pack1A2Data==null && !IsJoinUsPanelOpen && !IsAquariumOpen && !IsOpenDrop && OpenAquariumID==0 && requiredCount==0 && requiredRankCount==0 && curActivateItemId==0;
|
||||
#endif
|
||||
|
||||
public void InitData(Dictionary<string, string> userDatas)
|
||||
{
|
||||
InitTurntableRoll(_tables.TbGlobalConfig.InitWheelTicket);
|
||||
|
||||
@@ -56,6 +56,20 @@ namespace GameCore
|
||||
public string popupPanelNode;
|
||||
public GuidanceGroupDefine curGroupDefine;
|
||||
public GuidanceDefineSetting guidanceDefineSetting;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVGuideState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeGuides(raw,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
GuideSaveDataDic = new Dictionary<string,GuideSaveData>(StringComparer.Ordinal);
|
||||
curSaveData = null; curDefine = null; curGroupDefine = null; popupPanelNode = null; CurPanelName = null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVGuideState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
GuideSaveDataDic = state.Values.ToDictionary(p=>p.Key,
|
||||
p=>new GuideSaveData { guideSubIndex=p.Value.Index, isFinish=p.Value.Finished, groupName=p.Value.Group },StringComparer.Ordinal);
|
||||
}
|
||||
//待释放
|
||||
IDisposable disposable;
|
||||
IDisposable disposable2;
|
||||
@@ -101,6 +115,13 @@ namespace GameCore
|
||||
|
||||
if (GuideSaveDataDic.TryGetValue(item.groupName.ToString(), out _saveData))
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if (DeferUnavailablePotionGuide(item, _saveData))
|
||||
{
|
||||
keyValuePairs[item.groupName.ToString()] = _saveData;
|
||||
continue;
|
||||
}
|
||||
#endif
|
||||
if (!_saveData.isFinish && _saveData.guideSubIndex > 0)
|
||||
{
|
||||
for (int i = _saveData.guideSubIndex; i < item.guidanceDefines.Length; i++)
|
||||
@@ -153,6 +174,29 @@ namespace GameCore
|
||||
}
|
||||
}
|
||||
}
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
private static bool DeferUnavailablePotionGuide(GuidanceGroupDefine group, GuideSaveData saved)
|
||||
{
|
||||
if (!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled || !game.PlayerDataStorage.PlayerKVGameplayLoop.Ready ||
|
||||
group.groupName != GroupName.EventPotion01 || saved == null || saved.isFinish || saved.guideSubIndex <= 0 ||
|
||||
group.guidanceDefines == null || saved.guideSubIndex >= group.guidanceDefines.Length ||
|
||||
group.guidanceDefines[saved.guideSubIndex] == null ||
|
||||
group.guidanceDefines[saved.guideSubIndex].panelName != "EventPotionPanel")
|
||||
return false;
|
||||
try
|
||||
{
|
||||
// Existing potion activity classification; availability query does not initialize activity data.
|
||||
const int potionEventType = 4;
|
||||
const int potionEventSubType = 5;
|
||||
return GContext.container.Resolve<FishingEventData>().GetEvent(potionEventType, potionEventSubType) == -1;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("Potion guide availability query failed; retaining original guide recovery: " + ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
public bool GetIsFinish(string groupName)
|
||||
{
|
||||
return GuideSaveDataDic.ContainsKey(groupName) && GuideSaveDataDic[groupName].isFinish;
|
||||
|
||||
@@ -34,6 +34,42 @@ namespace GameCore
|
||||
public const string Diamond = "DiamondTrue";
|
||||
Dictionary<string, string> NewPlayerDataDic = new Dictionary<string, string>();
|
||||
public IReadOnlyDictionary<string, string> PlayerDataDic => NewPlayerDataDic;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVPlayerState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePlayer(raw,modern,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
NewPlayerDataDic=new Dictionary<string,string>(StringComparer.Ordinal); vipScoreDic=new Dictionary<int,int>();
|
||||
newRodList=new List<int>();
|
||||
lv=vip=vipAllScore=0; vipLastScore=PriceLv=0; priceLevel=null;
|
||||
energy=pearl=diamond=equipRodID=magnification=seasonId=rewardNewMap=0;
|
||||
gold=0; goldRM=0; energyRM=0; _currentMapId=_lastMapId=0;
|
||||
_fishingFrequency=_allFishingFrequency=0; _lastEnergyRecoveryTime=DateTime.MinValue;
|
||||
IsOpenAppraise=false; InfiniteBuildingData=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPlayerState state)
|
||||
{
|
||||
if(state==null || state.Core==null) throw new ArgumentNullException(nameof(state));
|
||||
var modern=state.Modern.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
var scores=state.VipScores.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
var rods=new List<int>(state.NewRods);
|
||||
var core=state.Core;
|
||||
lv=core.Integer(0); gold=core.Unsigned(1); vipAllScore=core.Integer(2); energy=core.Integer(3);
|
||||
pearl=core.Integer(4); magnification=core.Integer(5); equipRodID=core.Integer(6); _lastEnergyRecoveryTime=core.EnergyTime;
|
||||
rewardNewMap=core.Integer(8); energyRM=core.Integer(9); goldRM=core.Unsigned(10); diamond=core.Integer(11);
|
||||
_currentMapId=core.Integer(12); _lastMapId=core.Integer(13); _fishingFrequency=core.Integer(14); _allFishingFrequency=core.Integer(15);
|
||||
NewPlayerDataDic=modern; vipScoreDic=scores; newRodList=rods;
|
||||
seasonId=state.Season; IsOpenAppraise=state.Appraise; InfiniteBuildingData=state.Building;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal game.PlayerDataStorage.PlayerKVPlayerState SnapshotForKVValidation() =>
|
||||
new game.PlayerDataStorage.PlayerKVPlayerState(new game.PlayerDataStorage.PlayerKVCoreState(new object[] {
|
||||
(int)lv,(ulong)gold,(int)vipAllScore,(int)energy,(int)pearl,(int)magnification,(int)equipRodID,_lastEnergyRecoveryTime,
|
||||
(int)rewardNewMap,energyRM,goldRM,(int)diamond,(int)_currentMapId,(int)_lastMapId,_fishingFrequency,_allFishingFrequency,0,0,0
|
||||
}),NewPlayerDataDic,vipScoreDic,newRodList,seasonId,IsOpenAppraise,InfiniteBuildingData);
|
||||
internal bool IsDerivedEmptyForKVValidation => (int)vip==0 && vipLastScore==0 && PriceLv==0 && priceLevel==null;
|
||||
#endif
|
||||
public ObscuredInt lv { get; set; }
|
||||
public ObscuredInt vip { get; private set; }
|
||||
public ObscuredInt vipAllScore { get; private set; }
|
||||
@@ -1103,6 +1139,57 @@ namespace GameCore
|
||||
public void SavePlayerData(bool remote = true)
|
||||
{
|
||||
GContext.Publish<PlayerData>(this);
|
||||
SerializeAndSavePlayerData(remote);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
internal async System.Threading.Tasks.Task ConsumeEnergyForKVLoopAsync(int cost,
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction action)
|
||||
{
|
||||
if(cost<=0 || Energy<cost) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing energy cost or insufficient energy.");
|
||||
int before=Energy;
|
||||
var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginFishingWrites(action);
|
||||
game.PlayerDataStorage.PlayerKVEditResult primary;
|
||||
try
|
||||
{
|
||||
using(batch)
|
||||
{
|
||||
ReduceEnergy(cost,false);
|
||||
GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy,cost));
|
||||
primary=SerializeAndSavePlayerData(true);
|
||||
}
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.Audit("fishing-energy-applied",new{before,cost,after=(int)Energy,writes=batch.Records.Count,localOnly=batch.Records.Count(r=>!r.Remote)});
|
||||
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmFishingAsync(action,batch,primary);
|
||||
}
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; }
|
||||
}
|
||||
internal async System.Threading.Tasks.Task SetEquipRodIDForKVLoopAsync(int id)
|
||||
{
|
||||
var batch=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginEquip();
|
||||
game.PlayerDataStorage.PlayerKVEditResult primary;
|
||||
try
|
||||
{
|
||||
using(batch)
|
||||
{
|
||||
equipRodID=id;
|
||||
GContext.container.Resolve<PlayerFishData>().SetRedPoint();
|
||||
GContext.Publish<PlayerData>(this);
|
||||
primary=SerializeAndSavePlayerData(true);
|
||||
}
|
||||
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmEquipAsync(batch,primary);
|
||||
}
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); throw; }
|
||||
}
|
||||
#endif
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
internal game.PlayerDataStorage.PlayerKVEditResult SaveFishingRewardForKVLoop()
|
||||
{
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardMainSave();
|
||||
return SerializeAndSavePlayerData(true);
|
||||
}
|
||||
#endif
|
||||
private game.PlayerDataStorage.PlayerKVEditResult SerializeAndSavePlayerData(bool remote)
|
||||
{
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.Append(lv).Append(",");
|
||||
sb.Append(gold).Append(",");
|
||||
@@ -1143,7 +1230,11 @@ namespace GameCore
|
||||
|
||||
NewPlayerDataDic["ct"] = GContext.container.Resolve<FishingEventData>().CTSerialize();
|
||||
NewPlayerDataDic["rt"] = GContext.container.Resolve<FishingEventData>().RTSerialize(); ;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return game.PlayerDataStorage.PlayerKVGameplayLoop.Write("NewPlayerDataDic",Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic),remote);
|
||||
#endif
|
||||
PlayFabMgr.Instance.UpdateUserDataValue("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote);
|
||||
return null;
|
||||
}
|
||||
public void Init()
|
||||
{
|
||||
|
||||
@@ -85,6 +85,46 @@ namespace GameCore
|
||||
public Dictionary<string, float> LuckDataDic = new Dictionary<string, float>();
|
||||
public bool IsOpenPiercing = false;
|
||||
public int RodSelectedInPack = 0;
|
||||
internal static game.PlayerDataStorage.PlayerKVFishState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
IReadOnlyDictionary<string,string> modern,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
|
||||
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFish(raw,modern,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
mapFishData=new Dictionary<int,Dictionary<int,FishWeightData>>(); fishData=new Dictionary<int,FishWeightData>(); fishCardData=new Dictionary<int,int>();
|
||||
mapProgress=new Dictionary<int,int>(); rodData=new Dictionary<int,int>(); rodLevelData=new Dictionary<int,int>();
|
||||
fishSwimToMap=new Dictionary<int,int>(); fishSwimToMapCount=new Dictionary<int,int>(); _honorlevel=new Dictionary<int,int>();
|
||||
LuckDataDic=new Dictionary<string,float>(StringComparer.Ordinal); rodAccessoriesData=null;
|
||||
InitBotLuck=0; InitFishLuck=0; BotCount=0; _isRedPoint_up=false; RodCanUp=false; RodCanUpLevel=false;
|
||||
MapTipforUnlocked=0; IsOpenMap=false; IsOpenRod=false; IsOpenPiercing=false; RodSelectedInPack=0;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFishState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
var maps=state.Maps.ToDictionary(m=>m.Key,m=>m.Value.ToDictionary(p=>p.Key,p=>new FishWeightData {
|
||||
Level=p.Value.Level,MaxG=p.Value.Weight,TotalCard=p.Value.Cards,TotalNum=p.Value.Number,NewFish=p.Value.New }));
|
||||
var flat=new Dictionary<int,FishWeightData>();
|
||||
foreach(var map in maps.Values) foreach(var p in map) flat.Add(p.Key,p.Value);
|
||||
mapFishData=maps; fishData=flat; fishCardData=state.Cards.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
rodData=state.Rods.ToDictionary(p=>p.Key,p=>p.Value); rodLevelData=state.RodLevels.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
fishSwimToMap=state.Swim.ToDictionary(p=>p.Key,p=>p.Value); fishSwimToMapCount=state.SwimCount.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
mapProgress=state.Progress.ToDictionary(p=>p.Key,p=>p.Value); _honorlevel=state.Honor.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
LuckDataDic=state.Luck.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
var a=state.Accessories;
|
||||
rodAccessoriesData=a==null ? null : new RodGSAData { GloveID=a.Glove,RodToSkin=a.RodSkin.ToDictionary(p=>p.Key,p=>p.Value),
|
||||
GloveIds=new List<int>(a.Gloves),SkinIds=new List<int>(a.Skins),GloveNewIds=new List<int>(a.NewGloves),SkinNewIds=new List<int>(a.NewSkins) };
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal FishWeightData FishForKVValidation(int map,int id) => mapFishData[map][id];
|
||||
internal bool IndexesMatchForKVValidation(int map,int id,int card) => ReferenceEquals(mapFishData[map][id],fishData[id]) && fishCardData[card]==fishData[id].TotalCard;
|
||||
internal bool EmptyForKVValidation => mapFishData.Count==0 && fishData.Count==0 && fishCardData.Count==0 && mapProgress.Count==0 && rodData.Count==0 && rodLevelData.Count==0 &&
|
||||
fishSwimToMap.Count==0 && fishSwimToMapCount.Count==0 && _honorlevel.Count==0 && LuckDataDic.Count==0 && rodAccessoriesData==null &&
|
||||
InitBotLuck==0 && InitFishLuck==0 && BotCount==0 && !_isRedPoint_up && !RodCanUp && !RodCanUpLevel && !IsOpenMap && !IsOpenRod && !IsOpenPiercing && MapTipforUnlocked==0 && RodSelectedInPack==0;
|
||||
internal bool DictionariesMatchForKVValidation(game.PlayerDataStorage.PlayerKVFishState s) =>
|
||||
rodData.OrderBy(p=>p.Key).SequenceEqual(s.Rods.OrderBy(p=>p.Key)) && rodLevelData.OrderBy(p=>p.Key).SequenceEqual(s.RodLevels.OrderBy(p=>p.Key)) &&
|
||||
fishSwimToMap.OrderBy(p=>p.Key).SequenceEqual(s.Swim.OrderBy(p=>p.Key)) && fishSwimToMapCount.OrderBy(p=>p.Key).SequenceEqual(s.SwimCount.OrderBy(p=>p.Key)) &&
|
||||
mapProgress.OrderBy(p=>p.Key).SequenceEqual(s.Progress.OrderBy(p=>p.Key)) && _honorlevel.OrderBy(p=>p.Key).SequenceEqual(s.Honor.OrderBy(p=>p.Key));
|
||||
#endif
|
||||
|
||||
public void InitData(Dictionary<string, string> userDatas)
|
||||
{
|
||||
string data_value;
|
||||
@@ -1154,6 +1194,14 @@ namespace GameCore
|
||||
SetFishData(id, fishData[id]);
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
internal void SaveCaughtFishForKVLoop(int fishId)
|
||||
{
|
||||
if(!fishData.TryGetValue(fishId,out var data)) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Caught fish data is missing.");
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.CheckCaughtFishSave(fishId,data.TotalNum);
|
||||
SetFishData(fishId,data);
|
||||
}
|
||||
#endif
|
||||
void SetFishData(int id, FishWeightData data)
|
||||
{
|
||||
int mapID = _tables.TbFishData.GetOrDefault(id).AreaID;
|
||||
|
||||
@@ -126,6 +126,44 @@ namespace GameCore
|
||||
Dictionary<int, int> advertCount = new Dictionary<int, int>();
|
||||
#endregion
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVShopState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
|
||||
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeShop(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
shopPackDailyBuyCount=new Dictionary<string,int>(StringComparer.Ordinal); shopAllPackBuyCount=new Dictionary<int,int>();
|
||||
shopGiftBuyCount=new Dictionary<int,int>(); buyData=new List<float>(); advertCount=new Dictionary<int,int>();
|
||||
Voucher=0; ChainPackData=null; VIPAdPackData=null; TurntableAdData=null;
|
||||
_diamondShopItemRecords=new Dictionary<int,ItemShopRecord>(); _diamondShopRefreshCount=0; _diamondShopRefreshTime=default;
|
||||
CouponData.ResetForKVLoad();
|
||||
}
|
||||
static DailySignEvent KVDaily(game.PlayerDataStorage.PlayerKVDailyValue value) => value==null ? null :
|
||||
new DailySignEvent { ID=value.Id,index=value.Index,lastID=value.LastId,time=value.Time };
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVShopState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
shopPackDailyBuyCount=state.Daily.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
shopAllPackBuyCount=state.All.ToDictionary(p=>p.Key,p=>p.Value); shopGiftBuyCount=state.Gift.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
advertCount=state.Advert.ToDictionary(p=>p.Key,p=>p.Value); buyData=new List<float>(state.Buy); Voucher=state.Voucher;
|
||||
ChainPackData=KVDaily(state.Chain); TurntableAdData=KVDaily(state.Turntable);
|
||||
VIPAdPackData=state.Vip==null ? null : new AdvertData { ID=state.Vip.Id,index=state.Vip.Index,day=state.Vip.Day,cdTime=state.Vip.Time,isOpen=state.Vip.Open };
|
||||
_diamondShopItemRecords=state.Items.ToDictionary(p=>p.Key,p=>new ItemShopRecord(p.Value.Id,p.Value.Discount,p.Value.Bought));
|
||||
_diamondShopRefreshCount=state.RefreshCount; _diamondShopRefreshTime=state.RefreshTime; CouponData.ReplaceFromKVDecoded(state.Coupon);
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal game.PlayerDataStorage.PlayerKVShopState SnapshotForKVValidation()
|
||||
{
|
||||
game.PlayerDataStorage.PlayerKVDailyValue Daily(DailySignEvent v) => v==null ? null : new game.PlayerDataStorage.PlayerKVDailyValue(v.ID,v.index,v.lastID,v.time);
|
||||
return new game.PlayerDataStorage.PlayerKVShopState(shopPackDailyBuyCount,shopAllPackBuyCount,shopGiftBuyCount,advertCount,
|
||||
_diamondShopItemRecords.ToDictionary(p=>p.Key,p=>new game.PlayerDataStorage.PlayerKVShopItemValue(p.Value.ItemID,p.Value.Discount,p.Value.hasBought)),
|
||||
buyData,Voucher,_diamondShopRefreshCount,_diamondShopRefreshTime,Daily(ChainPackData),Daily(TurntableAdData),
|
||||
VIPAdPackData==null ? null : new game.PlayerDataStorage.PlayerKVAdvertValue(VIPAdPackData.ID,VIPAdPackData.index,VIPAdPackData.day,VIPAdPackData.cdTime,VIPAdPackData.isOpen),
|
||||
new game.PlayerDataStorage.PlayerKVCouponState(CouponData.ActivateTime,CouponData.EventId,CouponData.RedirectId,CouponData.IsBought));
|
||||
}
|
||||
internal void MutateCollectionsForKVValidation()
|
||||
{ buyData.Clear(); advertCount.Clear(); _diamondShopItemRecords.Values.First().Discount=99; }
|
||||
#endif
|
||||
|
||||
public void InitData(Dictionary<string, string> userDatas)
|
||||
{
|
||||
if (userDatas == null || userDatas.Count < 1)
|
||||
|
||||
@@ -37,6 +37,19 @@ namespace GameCore
|
||||
|
||||
MonthlySignEvent _monthlySignEvent = null;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVMonthlySignState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeMonthlySign(raw,errors);
|
||||
internal void ResetForKVLoad() { _monthlySignEvent = null; }
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVMonthlySignState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
_monthlySignEvent = !state.IsPresent ? null : new MonthlySignEvent
|
||||
{
|
||||
index = state.Index,
|
||||
dailySignEvent = new DailySignEvent { ID=state.DailyId, index=state.DailyIndex, lastID=state.LastId, time=state.Time }
|
||||
};
|
||||
}
|
||||
|
||||
public DailySignEvent dailySignEvent
|
||||
{
|
||||
get => _monthlySignEvent == null ? null : _monthlySignEvent.dailySignEvent;
|
||||
|
||||
@@ -47,6 +47,26 @@ namespace GameCore
|
||||
public int FishHuntGameCount => _fishHuntGameCount;
|
||||
public int BombGameCount => _bombGameCount;
|
||||
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
_heistGameCount=_bombGameCount=_fishHuntGameCount=0;
|
||||
opponentItemDatas=new List<OpponentItemData>(); opponentFriendItemDatas=new List<OpponentItemData>();
|
||||
opponentNotFriendItemDatas=new List<OpponentItemData>(); opponentFriend=new List<OpponentItemData>();
|
||||
opponentFriendDic=new Dictionary<string,OpponentItemData>(); opponentEnemy=new Dictionary<EnemyType,List<OpponentItemData>>();
|
||||
opponentItemDatasPre=new List<OpponentItemData>(); opponentFriendItemDatasPre=new List<OpponentItemData>();
|
||||
opponentNotFriendItemDatasPre=new List<OpponentItemData>(); powerData=new Dictionary<string,float>();
|
||||
opponentData=null; nextOpponent=null; curEnemyType=EnemyType.Aquarium; RobotMapId=nextRobotMapId=0;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCoreState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
_heistGameCount=state.Integer(16); _bombGameCount=state.Integer(17); _fishHuntGameCount=state.Integer(18);
|
||||
}
|
||||
internal bool IsOpponentEmptyForKVValidation => opponentItemDatas.Count==0 && opponentFriendItemDatas.Count==0 &&
|
||||
opponentNotFriendItemDatas.Count==0 && opponentFriend.Count==0 && opponentFriendDic.Count==0 && opponentEnemy.Count==0 &&
|
||||
opponentItemDatasPre.Count==0 && opponentFriendItemDatasPre.Count==0 && opponentNotFriendItemDatasPre.Count==0 &&
|
||||
powerData.Count==0 && opponentData==null && nextOpponent==null && RobotMapId==0 && nextRobotMapId==0;
|
||||
|
||||
//public UnityEngine.Vector3 image_extra_pos { set; get; }
|
||||
|
||||
public void SetHeistGameCount(int value)
|
||||
|
||||
@@ -52,6 +52,29 @@ namespace GameCore
|
||||
|
||||
//VIP 等级叠加
|
||||
public Dictionary<string, GiftVIPDData> giftVIPDData = new Dictionary<string, GiftVIPDData>();
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVTriggerState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodeTriggers(raw,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
triggerPackList=new List<TriggerPackBuyData>();
|
||||
giftVIPDData=new Dictionary<string,GiftVIPDData>(StringComparer.Ordinal);
|
||||
fixedTimeShopDic=new Dictionary<int,FixedTimeEvent>();
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVTriggerState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
var packs=state.Packs.Select(p=>new TriggerPackBuyData {
|
||||
ID=p.ID,index=p.index,lastID=p.lastID,vipLevel=p.vipLevel,mapID=p.mapID,time=p.time }).ToList();
|
||||
var gifts=state.Gifts.ToDictionary(p=>p.Key,p=>new GiftVIPDData {
|
||||
oldVipLevel=p.Value.oldVipLevel,curVipLevel=p.Value.curVipLevel,timer=p.Value.timer },StringComparer.Ordinal);
|
||||
var fixedValues=state.Fixed.ToDictionary(p=>p.Key,p=>new FixedTimeEvent { id=p.Value.id,endTime=p.Value.endTime });
|
||||
triggerPackList=packs; giftVIPDData=gifts; fixedTimeShopDic=fixedValues;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal FixedTimeEvent FixedForKVValidation(int key) => fixedTimeShopDic.TryGetValue(key,out var value) ? value : null;
|
||||
internal bool IsEmptyForKVValidation => triggerPackList.Count==0 && giftVIPDData.Count==0 && fixedTimeShopDic.Count==0;
|
||||
#endif
|
||||
public void Init()
|
||||
{
|
||||
playerShopData = GContext.container.Resolve<PlayerShopData>();
|
||||
|
||||
@@ -19,6 +19,19 @@ public class EventPackData
|
||||
}
|
||||
}
|
||||
private Data _data;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVEventPackState DecodeForKVLoad(System.Collections.Generic.IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeEventPack(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad() { _data=default(Data); }
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEventPackState state)
|
||||
{
|
||||
if (state == null) throw new System.ArgumentNullException(nameof(state));
|
||||
_data=new Data { currentEventID=state.EventId,purchaseCount=state.PurchaseCount,
|
||||
VIPLvlWhenEventActivate=state.VipLevel,redirectID=state.RedirectId };
|
||||
}
|
||||
internal game.PlayerDataStorage.PlayerKVEventPackState SnapshotForKVValidation() =>
|
||||
new game.PlayerDataStorage.PlayerKVEventPackState(_data.currentEventID,_data.purchaseCount,_data.VIPLvlWhenEventActivate,_data.redirectID);
|
||||
public int CurrentEventID { get { return _data.currentEventID; } }
|
||||
public int PurchaseCount { get { return _data.purchaseCount; } }
|
||||
public int VIPLvlWhenEventActivated { get { return _data.VIPLvlWhenEventActivate; } }
|
||||
|
||||
@@ -102,6 +102,51 @@ namespace DataCenter
|
||||
{
|
||||
private const bool EnableTraceLogs = false;
|
||||
public const string SaveKey = "EventPartnerGatherData";
|
||||
internal static game.PlayerDataStorage.PlayerKVPartnerState DecodeForKVLoad(
|
||||
IReadOnlyDictionary<string,string> raw,game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => game.PlayerDataStorage.PlayerKVSaveSchema.DecodePartner(raw,snapshot,errors);
|
||||
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
// Only a fresh controlled container is admitted. Never detach active gameplay work.
|
||||
if(_isInit || _act!=null || _disposables!=null || _autoMatchStatusInFlight!=null || PartnerInfoManager.HasSubscriptionsForKVLoad)
|
||||
throw new InvalidOperationException("Active partner module cannot enter KV load.");
|
||||
var partner=new PartnerInfoManager();
|
||||
var robot=new EventPartnerGatherRobotInvitationSystem();
|
||||
GatherData=null; _isEventDataChanged=false; CurrentBuildPartner=null;
|
||||
PartnerInfoManager=partner; RobotInvitationSystem=robot;
|
||||
_fishingEvent=null; EventPartnerMineMainData=null; EventPartnerMineSpinPointList=null; EventPartnerMineInitData=null;
|
||||
_nodeCumulativeSteps.Clear(); _nodeSteps.Clear(); _stageDropList.Clear(); StartTime=default; EndTime=default;
|
||||
_isInit=false; _spinId=-1; _userGm=false; BonusPercent=0; BonusExpireDuration=0;
|
||||
_dateTimeForGetEvent=default; startTime=0; delta=0; _lastAutoMatchStatusUtc=default; _cachedAutoMatchStatus=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPartnerState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
if(!state.Present) { GatherData=null; return; }
|
||||
GatherData=new EventPartnerGatherData
|
||||
{
|
||||
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
|
||||
CurrentPoints=state.CurrentPoints,CurrentMiningProgress=state.CurrentMiningProgress,Token=state.Token,
|
||||
InvitedCount=state.InvitedCount,TicketCount=state.TicketCount,
|
||||
CompletedNodesList=state.CompletedNodesList.Select(n=>new List<int>(n)).ToList(),
|
||||
BuilderPartnerScore=new List<(int,int)>(state.BuilderPartnerScore),PartnerBonus=(state.BonusPercent,state.BonusExpireUtc),
|
||||
FreeSpinNum=state.FreeSpinNum,Bots=state.Bots.ToDictionary(p=>p.Key,p=>new EventBuildBotInfo
|
||||
{ Id=p.Value.Id,ScorePlayer=p.Value.ScorePlayer,ScoreBot=p.Value.ScoreBot,Grade=p.Value.Grade,
|
||||
TargetScore=p.Value.TargetScore,WakeStartTime=p.Value.WakeStartTime,WakingSeconds=p.Value.WakingSeconds }),
|
||||
IsRecvFinalReward=state.IsRecvFinalReward,UIFinishedPartners=new List<bool>(state.UIFinishedPartners),
|
||||
IsFinished=state.IsFinished,SpinState=(ESpinState)state.SpinState,InflationRate=state.InflationRate,
|
||||
IsEnableAutoMatch=state.IsEnableAutoMatch,LastAutoMatchTime=state.LastAutoMatchTime
|
||||
};
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal bool RuntimeEmptyForKVValidation => !_isInit && !_isEventDataChanged && _act==null && _disposables==null &&
|
||||
_autoMatchStatusInFlight==null && _cachedAutoMatchStatus==null && _fishingEvent==null && CurrentBuildPartner==null &&
|
||||
EventPartnerMineMainData==null && EventPartnerMineSpinPointList==null && EventPartnerMineInitData==null &&
|
||||
_nodeCumulativeSteps.Count==0 && _nodeSteps.Count==0 && _stageDropList.Count==0 && StartTime==default && EndTime==default &&
|
||||
_spinId==-1 && !_userGm && BonusPercent==0 && BonusExpireDuration==0 && _dateTimeForGetEvent==default &&
|
||||
startTime==0 && delta==0 && _lastAutoMatchStatusUtc==default && !PartnerInfoManager.HasSubscriptionsForKVLoad;
|
||||
#endif
|
||||
[Inject] public Tables _tables { get; set; }
|
||||
|
||||
[Inject] public EventPartnerService EventPartnerService { get; set; }
|
||||
|
||||
@@ -449,6 +449,38 @@ namespace game
|
||||
|
||||
#region 数据相关
|
||||
private EventPinballData m_PinballData;
|
||||
internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventPinballMain==null && m_TbEventPinballPoint==null &&
|
||||
m_TbEventPinballReward==null && m_UIPanel==null && m_InfoPanel==null && m_RewardList.Count==0 &&
|
||||
m_PointWeightList.Count==0 && !m_IsSimulatorMode && !m_IsMultipleMax && m_CumulativeResponseNumber==0 &&
|
||||
m_AccumulatedTriggerCount==0 && m_TemporaryScore==0 && m_TemporaryConsumedToken==0 && m_MagnificationListIndex==0 && m_BonusResponseNumber==10;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVPinballState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodePinball(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
m_PinballData=null; m_BonusResponseNumber=10; m_CumulativeResponseNumber=m_AccumulatedTriggerCount=m_TemporaryScore=m_TemporaryConsumedToken=m_MagnificationListIndex=0;
|
||||
m_IsMultipleMax=false; m_IsSimulatorMode=false; m_RewardList=new List<float>(); m_PointWeightList=new List<float>(); m_UIPanel=m_InfoPanel=null;
|
||||
m_Tables=null; m_TbEventPinballMain=null; m_TbEventPinballPoint=null; m_TbEventPinballReward=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPinballState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
m_PinballData=!state.IsPresent ? null : new EventPinballData
|
||||
{
|
||||
EventID=state.EventID,
|
||||
PinballMainID=state.PinballMainID,
|
||||
SpinPoint=state.SpinPoint,
|
||||
Scores=state.Scores,
|
||||
Multiple=state.Multiple,
|
||||
Token=state.Token,
|
||||
ConsumedToken=state.ConsumedToken,
|
||||
RewardIndex=state.RewardIndex,
|
||||
Force=state.Force,
|
||||
LuckyValue=state.LuckyValue,
|
||||
InflationRate=state.InflationRate
|
||||
};
|
||||
}
|
||||
public EventPinballData PinballData { get { return m_PinballData; } }
|
||||
public int RewardMaxIndex
|
||||
{
|
||||
@@ -957,4 +989,4 @@ namespace game
|
||||
|
||||
public const int MultipleMax = 10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,55 @@ namespace game
|
||||
|
||||
#region 数据相关
|
||||
private EventPotionData m_PotionData;
|
||||
|
||||
internal static PlayerDataStorage.PlayerKVPotionState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePotion(raw,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
m_PotionData=null; m_PendingFirstPlayedEventId=0; m_SpriteAtlas=null; m_Tables=null;
|
||||
m_TbEventPotionMain=null; m_TbEventPotionStage=null; m_TbEventPotionBlock=null; m_TbEventPotionBottle=null;
|
||||
m_PotionResDict=new Dictionary<int,EventPotionResource>(); m_WallsBefore=new List<PotionItemData>(); m_WallsAfter=new List<PotionItemData>();
|
||||
m_UIPanel=m_InfoPanel=m_RewardPopupPanel=null; m_ScrollerDataList=new List<PotionItemData>();
|
||||
}
|
||||
static PotionItemData MaterializeKVPotionItem(PlayerDataStorage.PlayerKVPotionItem value) => new PotionItemData {
|
||||
name=value.Name,index=value.Index,isOver=value.IsOver,DataType=(EventPotionDataType)value.DataType,
|
||||
ColorTypeList=new List<int>(value.Colors),ShelfTypeList=value.Shelves.Select(n=>(EventPotionShelfType)n).ToList(),
|
||||
GridStateList=value.Grids.Select(n=>(EventPotionGridState)n).ToList(),FromDisplayAreaIndexList=new List<int>(value.FromDisplay)
|
||||
};
|
||||
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPotionState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
m_PotionData=!state.IsPresent ? null : new EventPotionData {
|
||||
EventID=state.EventID,
|
||||
PotionID=state.PotionID,
|
||||
StageID=state.StageID,
|
||||
Stage1TotalLevel=state.Stage1TotalLevel,
|
||||
Stage2TotalLevel=state.Stage2TotalLevel,
|
||||
LevelID=state.LevelID,
|
||||
LevelIndex=state.LevelIndex,
|
||||
CorrectNum=state.CorrectNum,
|
||||
SubmitNum=state.SubmitNum,
|
||||
Token=state.Token,
|
||||
ConsumedToken=state.ConsumedToken,
|
||||
Rule=state.Rule,
|
||||
PreEventID=state.PreEventID,
|
||||
PrePotionID=state.PrePotionID,
|
||||
PreStageID=state.PreStageID,
|
||||
PreLevelID=state.PreLevelID,
|
||||
Round=state.Round,
|
||||
FirstPlayedEventId=state.FirstPlayedEventId,
|
||||
IsOver=state.IsOver,
|
||||
Name=state.Name,
|
||||
InflationRate=state.InflationRate,
|
||||
LevelTargetData=MaterializeKVPotionItem(state.LevelTargetData),
|
||||
LevelDisplayData=MaterializeKVPotionItem(state.LevelDisplayData),
|
||||
LevelProcessedList=state.LevelProcessedList.Select(MaterializeKVPotionItem).ToList()
|
||||
};
|
||||
}
|
||||
internal bool IsInactiveForKVValidation => m_PendingFirstPlayedEventId==0 && m_SpriteAtlas==null && m_Tables==null &&
|
||||
m_TbEventPotionMain==null && m_TbEventPotionStage==null && m_TbEventPotionBlock==null && m_TbEventPotionBottle==null &&
|
||||
m_PotionResDict.Count==0 && m_WallsBefore.Count==0 && m_WallsAfter.Count==0 && m_ScrollerDataList.Count==0 &&
|
||||
m_UIPanel==null && m_InfoPanel==null && m_RewardPopupPanel==null;
|
||||
public EventPotionData PotionData { get { return m_PotionData; } }
|
||||
|
||||
public void InitLevel()
|
||||
|
||||
@@ -94,6 +94,45 @@ namespace game
|
||||
|
||||
#region 数据相关
|
||||
private EventScratchTicketData m_ScratchTicketData;
|
||||
internal bool IsInactiveForKVValidation => m_Tables==null && m_TbEventScratchMain==null && m_TbEventScratchCard==null &&
|
||||
m_TbEventScratchReward==null && m_ChainPackData==null && m_NormalPackData==null && m_SpriteAtlas==null && m_SpriteDict==null &&
|
||||
m_UIPanel==null && m_InfoPanel==null && m_RewardPanel==null && m_ChainPackPanel==null && m_PackPanel==null &&
|
||||
m_RecordList.Count==0 && m_PointWeightList.Count==0 && m_ScratchStampList.Count==0 &&
|
||||
TempRecordRewardTaskID==0 && TempRecordRewardTaskConsumedToken==0;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVScratchState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeScratch(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
m_ScratchTicketData=null; TempRecordRewardTaskID=TempRecordRewardTaskConsumedToken=0; m_RecordList=new List<EventScratchTicketRecordData>();
|
||||
m_PointWeightList=new List<float>(); m_ScratchStampList=new List<int>(); m_ChainPackData=null; m_NormalPackData=null;
|
||||
m_UIPanel=m_InfoPanel=m_RewardPanel=m_ChainPackPanel=m_PackPanel=null; m_SpriteAtlas=null; m_SpriteDict=null;
|
||||
m_Tables=null; m_TbEventScratchMain=null; m_TbEventScratchCard=null; m_TbEventScratchReward=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVScratchState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
m_ScratchTicketData=!state.IsPresent ? null : new EventScratchTicketData
|
||||
{
|
||||
EventID=state.EventID,
|
||||
ScratchMainID=state.ScratchMainID,
|
||||
ChainPackID=state.ChainPackID,
|
||||
Token=state.Token,
|
||||
ConsumedToken=state.ConsumedToken,
|
||||
RewardTaskConsumedToken=state.RewardTaskConsumedToken,
|
||||
EventScratchRewardTaskID=state.EventScratchRewardTaskID,
|
||||
EventScratchCardID=state.EventScratchCardID,
|
||||
TotalConsumedToken=state.TotalConsumedToken,
|
||||
ChainPackProgress=state.ChainPackProgress,
|
||||
magIndex=state.magIndex,
|
||||
IsOver=state.IsOver,
|
||||
LuckyValue=state.LuckyValue,
|
||||
InflationRate=state.InflationRate,
|
||||
StampRewardList=new List<int>(state.StampRewardList),
|
||||
StampRewardDict=state.StampRewardDict.ToDictionary(p=>p.Key,p=>new EventScratchTicketOwnedStampRewardData { EventScratchCardID=p.Value.EventScratchCardID,Count=p.Value.Count })
|
||||
};
|
||||
}
|
||||
public EventScratchTicketData ScratchTicketData { get { return m_ScratchTicketData; } }
|
||||
public EventScratchMain GetFreshScratchTicketData()
|
||||
{
|
||||
|
||||
@@ -13,6 +13,20 @@ public class FishingBoxData
|
||||
public Dictionary<int, int> FishingNewBoxs = new();
|
||||
public Dictionary<int, int> FishingBoxOpenNum = new();
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVIntMapState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
IReadOnlyDictionary<string,string> modern, game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFishingBox(raw,modern,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
FishingNewBoxs = new Dictionary<int,int>();
|
||||
FishingBoxOpenNum = new Dictionary<int,int>();
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVIntMapState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
FishingBoxOpenNum = state.Values.ToDictionary(p=>p.Key,p=>p.Value);
|
||||
}
|
||||
|
||||
public void InitData(string data)
|
||||
{
|
||||
if (string.IsNullOrEmpty(data) == false)
|
||||
|
||||
@@ -61,6 +61,40 @@ public class FishingChallengeManager
|
||||
private DateTime EndTime;
|
||||
//
|
||||
public FishingChallengeData fishingChallengeData { get; private set; }
|
||||
internal bool IsInactiveForKVValidation => !IsInit && CurFishingEventData==null && eventChallengeMain==null &&
|
||||
eventChallengeConfig==null && challengeMapData==null && _stageDropList.Count==0 && _eliminateListScore.Count==0;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVChallengeState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeChallenge(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
fishingChallengeData=null; IsInit=false; PrefabName=null; TopRankTier=Rank=FullStep=PlayerFullNumber=PreScore=_cheatingScore=0;
|
||||
CurFishingEventData=null; StartTime=EndTime=default; eventChallengeMain=null; eventChallengeConfig=null; challengeMapData=null; itemDataList=null;
|
||||
_stageDropList.Clear(); _eliminateListScore.Clear(); _eventTrackStageList=new List<int>(); _eventTrackNormalRewardList=new List<int>(); InVisibleReason=string.Empty;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVChallengeState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
fishingChallengeData=!state.IsPresent || state.IsExplicitNull ? null : new FishingChallengeData
|
||||
{
|
||||
FirstOpen=state.FirstOpen,
|
||||
EventId=state.EventId,
|
||||
RankTierLevel=state.RankTierLevel,
|
||||
Finished=state.Finished,
|
||||
RedirectID=state.RedirectID,
|
||||
Score=state.Score,
|
||||
ProcessedScore=state.ProcessedScore,
|
||||
step=state.step,
|
||||
uiStep=state.uiStep,
|
||||
index=state.index,
|
||||
StartTime=state.StartTime,
|
||||
EndTime=state.EndTime,
|
||||
eliminateNumber=new List<int>(state.eliminateNumber),
|
||||
robotAccountList=new List<int>(state.robotAccountList),
|
||||
InflationRate=state.InflationRate
|
||||
};
|
||||
}
|
||||
public EventChallengeMain eventChallengeMain { get; private set; }
|
||||
public EventChallengeConfig eventChallengeConfig { get; private set; }
|
||||
public MapData challengeMapData { get; private set; }
|
||||
@@ -1071,4 +1105,4 @@ public class FishingChallengeManager
|
||||
_stageDropList.Clear();
|
||||
_eventTrackStageList = new();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,28 @@ namespace DataCenter
|
||||
public List<EventOfferChainsChestRewards> OfferChainsChestRewards { get; set; }
|
||||
// 当前可保存数据
|
||||
public TOfferChainsData OfferChainsData { get; private set; }
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,false,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
OfferChainsData=null; ValidIds=new List<int>(); _fishingEvent=null; _isInit=false;
|
||||
OfferChainsChestMain=null; OfferChainsChestRewards=null;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
OfferChainsData=!state.IsPresent ? null : new TOfferChainsData
|
||||
{
|
||||
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
|
||||
ItemId=state.ItemId,LotteryNum=state.LotteryNum,
|
||||
Items=state.Items.Select(item=>new TOfferChainsData.TItem {
|
||||
Id=item.Id,Item=item.Item,Number=item.Number,Status=item.Status }).ToList(),
|
||||
RewardList=new List<int>(state.RewardList),Finished=state.Finished,ChestReward=state.ChestReward
|
||||
};
|
||||
}
|
||||
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null;
|
||||
public List<int> ValidIds;
|
||||
|
||||
public bool IsFirstOpen
|
||||
@@ -546,4 +568,4 @@ namespace DataCenter
|
||||
return iAPItemList;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,28 @@ namespace DataCenter
|
||||
|
||||
public TOfferDiscoPartyData OfferDiscoPartyData { get; private set; }
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVOfferState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeOffer(raw,true,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
OfferDiscoPartyData=null; ValidIds=new List<int>(); _fishingEvent=null; _isInit=false;
|
||||
OfferDiscoParty=null; OfferDiscoPartyRewards=null; FreeTime=1;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVOfferState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
OfferDiscoPartyData=!state.IsPresent ? null : new TOfferDiscoPartyData
|
||||
{
|
||||
FirstOpen=state.FirstOpen,EventId=state.EventId,StartTime=state.StartTime,EndTime=state.EndTime,
|
||||
ItemId=state.ItemId,LotteryNum=state.LotteryNum,
|
||||
Items=state.Items.Select(item=>new TOfferDiscoPartyData.TItem {
|
||||
Id=item.Id,Item=item.Item,Number=item.Number,Status=(TItemStatues) item.Status }).ToList(),
|
||||
RewardList=new List<int>(state.RewardList)
|
||||
};
|
||||
}
|
||||
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent == null;
|
||||
|
||||
|
||||
public List<int> ValidIds; //
|
||||
|
||||
@@ -561,4 +583,4 @@ namespace DataCenter
|
||||
return iAPItemList;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,47 @@ public class PiggyBankPackData
|
||||
}
|
||||
}
|
||||
private Data _data;
|
||||
|
||||
private bool _kvLoadPresent;
|
||||
internal bool KVLoadPresent => _kvLoadPresent;
|
||||
internal static game.PlayerDataStorage.PlayerKVPiggyBankState DecodeForKVLoad(
|
||||
IReadOnlyDictionary<string, string> raw, game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodePiggyBank(raw, snapshot, errors);
|
||||
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
_kvLoadPresent = false;
|
||||
_data = default(Data);
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVPiggyBankState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
if (!state.IsPresent) { ResetForKVLoad(); return; }
|
||||
var replacement = new Data
|
||||
{
|
||||
currentEventID = state.currentEventID,
|
||||
purchaseCount = state.purchaseCount,
|
||||
VIPLvlWhenEventActivate = state.VIPLvlWhenEventActivate,
|
||||
savingProgress = state.savingProgress,
|
||||
redirectID = state.redirectID,
|
||||
doNeedTrigger = state.doNeedTrigger,
|
||||
isTriggered = state.isTriggered,
|
||||
inflation = state.inflation,
|
||||
};
|
||||
_data = replacement;
|
||||
_kvLoadPresent = true;
|
||||
}
|
||||
internal game.PlayerDataStorage.PlayerKVPiggyBankState SnapshotForKVValidation() =>
|
||||
new game.PlayerDataStorage.PlayerKVPiggyBankState(_kvLoadPresent, _data.currentEventID, _data.purchaseCount, _data.VIPLvlWhenEventActivate, _data.savingProgress, _data.redirectID, _data.doNeedTrigger, _data.isTriggered, _data.inflation);
|
||||
public bool IsWithinEventTime
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!_tables.TbFishingEvent.DataMap.Keys.Contains(_data.currentEventID))
|
||||
return false;
|
||||
var et = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).EndTime);
|
||||
var st = DateTime.Parse((_tables.TbFishingEvent[_data.currentEventID].TimeDefinition as LimitedTime).StartTime);
|
||||
var fishingEvent = _tables.TbFishingEvent.GetOrDefault(_data.currentEventID);
|
||||
if (!(fishingEvent?.TimeDefinition is LimitedTime time)) return false;
|
||||
var et = DateTime.Parse(time.EndTime);
|
||||
var st = DateTime.Parse(time.StartTime);
|
||||
return ZZTimeHelper.UtcNow() >= st && ZZTimeHelper.UtcNow() < et;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +168,34 @@ public class EventCubeMeltManager
|
||||
[Inject] public Tables _tables { get; set; }
|
||||
|
||||
public EventCubeMeltData EventData { get; private set; }
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVCubeState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaSnapshot snapshot,game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
game.PlayerDataStorage.PlayerKVSaveSchema.DecodeCube(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
EventData=null; _isInit=false; _fishingEvent=null;
|
||||
EventCubeMeltMainData=null; EventCubeMeltConfig=null; EventCubeMeltStageData=null; EventCubeMeltCollectionData=null;
|
||||
EventCubeMeltPieces=null; EventCubeMeltPieceItems=new List<PieceItem>(); EventCubeMeltItems=null; EventCubeMeltCollections=null;
|
||||
EventStageList=null; StageCount=0; StartTime=EndTime=default; CurrentPieceItems=new List<PieceItem>(); _pieceDimension=default;
|
||||
PrevData=null; NextData=null; PrevBoardData=null; PrevPieceDatas=null; CurrentBoardData=null; CurrentPieceDatas=null; GuideIndex=0;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVCubeState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
EventData=!state.IsPresent ? null : new EventCubeMeltData {
|
||||
TicketCount=state.TicketCount,FirstOpen=state.FirstOpen,IsFinished=state.IsFinished,EventId=state.EventId,
|
||||
IsInGuide=state.IsInGuide,StartTime=state.StartTime,EndTime=state.EndTime,CurrentStagetId=state.CurrentStagetId,
|
||||
CollectItems=state.CollectItems.Select(p=>new CollectItem { ItemId=p.ItemId,Count=p.Count,MaxCount=p.MaxCount,ItemTrueId=p.ItemTrueId }).ToList(),
|
||||
BoardData=new List<int>(state.BoardData),BoardViewSize=new Vector2Int(state.Width,state.Height),
|
||||
CollectRewardsId=new List<int>(state.CollectRewardsId),IsFinalRewardGet=state.IsFinalRewardGet,
|
||||
PieceDatas=state.PieceDatas.Select(p=>new List<int>(p)).ToList(),Cycle=state.Cycle,InflationRate=state.InflationRate
|
||||
};
|
||||
}
|
||||
internal bool IsInactiveForKVValidation => !_isInit && _fishingEvent==null && EventCubeMeltMainData==null &&
|
||||
EventCubeMeltStageData==null && EventCubeMeltCollectionData==null && EventCubeMeltPieces==null &&
|
||||
EventCubeMeltPieceItems.Count==0 && CurrentPieceItems.Count==0 && PrevData==null && NextData==null &&
|
||||
PrevBoardData==null && CurrentBoardData==null && PrevPieceDatas==null && CurrentPieceDatas==null;
|
||||
|
||||
public FishingEvent FishingEvent {get => _fishingEvent;set => _fishingEvent = value;}
|
||||
|
||||
@@ -1711,4 +1739,4 @@ public class EventCubeMeltManager
|
||||
var cubeMeltData = _tables.TbEventCubeMeltCollection.DataList.FirstOrDefault(data => data.ID == stageId);
|
||||
return cubeMeltData?.CollectingReward?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace EventPartnerGather
|
||||
public class PartnerInfoManager : IDisposable
|
||||
{
|
||||
// [Inject]
|
||||
private static Tables _tables { get; set; } = GContext.container.Resolve<Tables>();
|
||||
private readonly Tables _tables = GContext.container.Resolve<Tables>();
|
||||
private FriendService _friendService { get; set; } = GContext.container.Resolve<FriendService>();
|
||||
private IUserService _userService { get; } = GContext.container.Resolve<IUserService>();
|
||||
|
||||
@@ -41,9 +41,15 @@ namespace EventPartnerGather
|
||||
public int EventId { get; set; }
|
||||
|
||||
//
|
||||
public static readonly int BuilderCapacity = _tables.TbEventPartnerConfig.InvitationCount[1];
|
||||
// D4: current-context read. Unlike the old field, this is not frozen across configuration changes.
|
||||
public static int BuilderCapacity => GContext.container.Resolve<Tables>().TbEventPartnerConfig.InvitationCount[1];
|
||||
|
||||
public readonly BuildPartner[] BuildPartners = new BuildPartner[BuilderCapacity];
|
||||
public readonly BuildPartner[] BuildPartners;
|
||||
public PartnerInfoManager()
|
||||
{
|
||||
BuildPartners = new BuildPartner[_tables.TbEventPartnerConfig.InvitationCount[1]];
|
||||
}
|
||||
internal bool HasSubscriptionsForKVLoad => disposables != null;
|
||||
//
|
||||
public bool IsFullyMatched => BuildPartners.All(b => b != null && b.IsValid());
|
||||
|
||||
@@ -387,9 +393,9 @@ namespace EventPartnerGather
|
||||
public bool AddBuildPartner(BuildPartner buildPartner)
|
||||
{
|
||||
var index = buildPartner.SlotId;
|
||||
if (index >= BuilderCapacity)
|
||||
if (index >= BuildPartners.Length)
|
||||
{
|
||||
LogWarning($"AddBuildPartnerFailed eventId={EventId} reason=SlotOutOfRange slotId={index} capacity={BuilderCapacity}");
|
||||
LogWarning($"AddBuildPartnerFailed eventId={EventId} reason=SlotOutOfRange slotId={index} capacity={BuildPartners.Length}");
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -475,4 +481,4 @@ namespace EventPartnerGather
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using GameCore;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
@@ -23,6 +24,52 @@ public class ShowHandEvent
|
||||
[DefaultExecutionOrder(10000)]
|
||||
public partial class FishingBehaviorB : MonoBehaviour
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingAction kvFishingAction;
|
||||
bool KVFishingCurrent()
|
||||
{
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true;
|
||||
return this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.FishingCurrent(kvFishingAction);
|
||||
}
|
||||
void CancelKVFishing(string reason) => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(kvFishingAction,reason);
|
||||
void TrackKVCastTween(Tween tween)
|
||||
{
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
|
||||
var action=kvFishingAction;
|
||||
bool completed=false;
|
||||
tween.onComplete+=()=>completed=true;
|
||||
tween.onKill+=()=> { if(!completed) game.PlayerDataStorage.PlayerKVGameplayLoop.CancelFishing(action,"cast tween killed"); };
|
||||
}
|
||||
async Task ConsumeBaitOnKVAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!KVFishingCurrent()) return;
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.ClaimFishingConsumption(kvFishingAction)) return;
|
||||
if(fishingData.fishItem==null || !fishingData.IsGeneralFish() || fishingData.IsFree || fishingData.IsRetry ||
|
||||
fishingData.IsDuel || fishingData.IsPackItemFish() || fishingData.curFishData==null)
|
||||
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Only one ordinary paid fishing action is permitted. fishItem.ID="+(fishingData.fishItem==null?"null":fishingData.fishItem.ID.ToString())+", fishItem.SubType="+(fishingData.fishItem==null?"null":fishingData.fishItem.SubType.ToString())+", curFishData.ID="+(fishingData.curFishData==null?"null":fishingData.curFishData.ID.ToString())+", IsFree="+fishingData.IsFree+", IsRetry="+fishingData.IsRetry+", IsDuel="+fishingData.IsDuel);
|
||||
if(Application.internetReachability==NetworkReachability.NotReachable)
|
||||
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing stopped before consumption: offline.");
|
||||
if(_tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==ConditionType.ConsumeEnergy)))
|
||||
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("ConsumeEnergy activity activation requires separate review.");
|
||||
var player=GContext.container.Resolve<PlayerData>();
|
||||
int mag=player.magnification;
|
||||
if(mag<0 || mag>=_tables.TbEnergyDef.DataList.Count)
|
||||
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Invalid fishing magnification.");
|
||||
int fish=fishingData.curFishData.ID, rod=fishingData.RodId, map=player.lastMapId;
|
||||
int cost=_tables.TbEnergyDef.DataList[mag].Mag;
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.PrepareFishingReward(kvFishingAction,fishingData);
|
||||
await player.ConsumeEnergyForKVLoopAsync(cost,kvFishingAction);
|
||||
if(!KVFishingCurrent()) return;
|
||||
if(fishingData.curFishData==null || fishingData.curFishData.ID!=fish || fishingData.RodId!=rod ||
|
||||
player.lastMapId!=map || (int)player.magnification!=mag || fishingData.IsFree || fishingData.IsRetry || fishingData.IsDuel)
|
||||
throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Fishing context changed during confirmation.");
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.ContinueFishing(kvFishingAction,WaitForFishBiteED);
|
||||
}
|
||||
catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
|
||||
}
|
||||
#endif
|
||||
Tables _tables;
|
||||
[Header("RELATED OBJECT SETTINGS")]
|
||||
[SerializeField]
|
||||
@@ -213,7 +260,13 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
{
|
||||
Addressables.ReleaseInstance(handAB);
|
||||
}
|
||||
handAB = await Addressables.InstantiateAsync(gloveData.Fbx, transform).Task;
|
||||
var loadedHand = await Addressables.InstantiateAsync(gloveData.Fbx, transform).Task;
|
||||
if (this == null)
|
||||
{
|
||||
if (loadedHand != null) Addressables.ReleaseInstance(loadedHand);
|
||||
return;
|
||||
}
|
||||
handAB = loadedHand;
|
||||
if (handAB != null)
|
||||
{
|
||||
HandController = handAB.GetComponent<HandController>();
|
||||
@@ -225,7 +278,13 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
async void LoadBait()
|
||||
{
|
||||
baitAB = await Addressables.InstantiateAsync("BaitRoot", _bait.transform).Task;
|
||||
var loadedBait = await Addressables.InstantiateAsync("BaitRoot", _bait.transform).Task;
|
||||
if (this == null)
|
||||
{
|
||||
if (loadedBait != null) Addressables.ReleaseInstance(loadedBait);
|
||||
return;
|
||||
}
|
||||
baitAB = loadedBait;
|
||||
baitAB.transform.localPosition = Vector3.zero;
|
||||
baitAB.transform.localRotation = Quaternion.identity;
|
||||
baitAB.transform.localScale = Vector3.one;
|
||||
@@ -421,10 +480,20 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
|
||||
float FishingScale = fishRodSkinData.FishingScale;
|
||||
var goPrefab = await Addressables.LoadAssetAsync<GameObject>(path).Task;
|
||||
if (this == null)
|
||||
{
|
||||
if (goPrefab != null) Addressables.Release(goPrefab);
|
||||
return;
|
||||
}
|
||||
if (goPrefab == null)
|
||||
{
|
||||
Debug.LogError("Load Rod Elemental as Default Rod");
|
||||
goPrefab = await Addressables.LoadAssetAsync<GameObject>("Rod_elemental").Task;
|
||||
if (this == null)
|
||||
{
|
||||
if (goPrefab != null) Addressables.Release(goPrefab);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_bait.transform.SetParent(transform);
|
||||
if (goPrefab != null)
|
||||
@@ -690,9 +759,25 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
//开始抛鱼线
|
||||
public async void CastLineBehavior()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
var action=game.PlayerDataStorage.PlayerKVGameplayLoop.BeginFishing(this);
|
||||
if(action==null) return;
|
||||
kvFishingAction=action;
|
||||
if(!KVFishingCurrent()) return;
|
||||
}
|
||||
catch(Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; }
|
||||
}
|
||||
#endif
|
||||
//计算主画布分辨率下的宽高和屏幕的宽高的比列:
|
||||
fishingData.mainCamera.DOLocalRotateQuaternion(Quaternion.identity, 0.1f);
|
||||
await Awaiters.NextFrame;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
|
||||
CastBait();
|
||||
//播放抛竿动画
|
||||
@@ -703,12 +788,18 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
rodHandleAni.SetBool("_idle", false);
|
||||
rodHandleAni.SetTrigger("_drawing");
|
||||
await Awaiters.Seconds(fishingBehaviorConf.audioDelay);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
GContext.Publish(new EventFishingSound(SoundType.ThrowingAudio));
|
||||
GContext.Publish(new VibrationData(1));
|
||||
}
|
||||
|
||||
private void ConsumeBait()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=ConsumeBaitOnKVAsync(); return; }
|
||||
#endif
|
||||
if (fishingData.IsFree)
|
||||
{
|
||||
WaitForFishBiteED();
|
||||
@@ -910,6 +1001,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
public void BaitMove()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
flyEndPosTrue = _bait.transform.position;
|
||||
|
||||
GContext.Publish(new EventFishingSound(SoundType.audio_fishing_throwing_baitintowater));
|
||||
@@ -920,9 +1014,12 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
var fishEffect = _effectDic["fx_fishing_splashloopwaiting"];
|
||||
float z = WaitingZ();
|
||||
float timer = Mathf.Lerp(fishingBehaviorConf.waitingLineDrawMinTime, fishingBehaviorConf.waitingLineDrawMaxTime, Random.value);
|
||||
_bait.transform.DOLocalMoveZ(z, timer).OnUpdate(
|
||||
var waitingTween=_bait.transform.DOLocalMoveZ(z, timer).OnUpdate(
|
||||
(() =>
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
flyEndPosTrue = _bait.transform.position;
|
||||
if (fishEffect)
|
||||
{
|
||||
@@ -931,6 +1028,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
fishEffect.transform.position = vector;
|
||||
}
|
||||
})); ;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
TrackKVCastTween(waitingTween);
|
||||
#endif
|
||||
StartCoroutine(WaitForFishBite(timer));
|
||||
}
|
||||
IEnumerator PlayWaitingFx()
|
||||
@@ -953,6 +1053,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
private IEnumerator WaitForFishBite(float timer)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) yield break;
|
||||
#endif
|
||||
EventFishingSound eventFishingSound1 = new EventFishingSound(SoundType.audio_fishing_waiting);
|
||||
EventFishingSound eventFishingSound2 = new EventFishingSound(SoundType.ReelingAudio, loop: true);
|
||||
|
||||
@@ -962,6 +1065,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
timer += Mathf.Lerp(fishingBehaviorConf.waitingMinTime, fishingBehaviorConf.waitingMaxTime, Random.value) * (1 - EnhancedWaitingTime);
|
||||
|
||||
yield return new WaitForSeconds(timer);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) yield break;
|
||||
#endif
|
||||
|
||||
if (eventFishingSound2.Sound != null)
|
||||
{
|
||||
@@ -1085,18 +1191,27 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
private async void CastBait()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
InitEffect();
|
||||
if (fishingBehaviorConf.fishFreeControlTime > 0f)
|
||||
{
|
||||
await new WaitForSeconds(fishingBehaviorConf.fishFreeControlTime);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
_isCastLine = true;
|
||||
_bait.transform.SetParent(transform);
|
||||
_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime);
|
||||
_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve);
|
||||
var castX=_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime);
|
||||
var castZ=_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve);
|
||||
_cameraAnimator.SetTrigger("_waiting");
|
||||
_bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime)
|
||||
var castY=_bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime)
|
||||
.SetEase(fishingBehaviorConf.flyDownCurve).OnComplete((() =>
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
fishingRodAnimator.SetTrigger("_waiting");
|
||||
GContext.Publish(ERightState.Waiting);
|
||||
LoadEffect("fx_fishing_splashinto");
|
||||
@@ -1106,16 +1221,25 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
BaitMove();
|
||||
_bait.SetActive(false);
|
||||
}));
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
TrackKVCastTween(castX); TrackKVCastTween(castZ); TrackKVCastTween(castY);
|
||||
#endif
|
||||
AddLinePos();
|
||||
}
|
||||
else
|
||||
{
|
||||
await new WaitForSeconds(1.15f);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
//当前动画_fishingRodAnimator播放时间
|
||||
float time = fishingRodAnimator.GetCurrentAnimatorStateInfo(0).normalizedTime;
|
||||
_isCastLine = true;
|
||||
_cameraAnimator.SetTrigger("_waiting");
|
||||
await new WaitForSeconds(time);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!KVFishingCurrent()) return;
|
||||
#endif
|
||||
_bait.transform.SetParent(transform);
|
||||
_bait.SetActive(false);
|
||||
//鱼漂位置
|
||||
@@ -1259,7 +1383,7 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
void SetIKTarget(Transform target)
|
||||
{
|
||||
if (RodBehaviour.cCDIK)
|
||||
if (RodBehaviour != null && RodBehaviour.cCDIK)
|
||||
{
|
||||
RodBehaviour.cCDIK.solver.target = target;
|
||||
}
|
||||
@@ -1896,6 +2020,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
_cameraAnimator.SetBool("_reeling", false);
|
||||
fishingRodAnimator.SetBool("_pulling", false);
|
||||
CameraPullingEnd();
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled && (!KVFishingCurrent() || !game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance)) yield break;
|
||||
#endif
|
||||
fishingData.IsCaughtFish = true;
|
||||
PlayCollectingEffect();
|
||||
if (fx_fishing_splashwaveloop != null)
|
||||
@@ -2308,6 +2435,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
CancelKVFishing("disabled");
|
||||
#endif
|
||||
CloseAllEffect();
|
||||
}
|
||||
public void CloseAllEffect()
|
||||
@@ -2331,6 +2461,9 @@ public partial class FishingBehaviorB : MonoBehaviour
|
||||
}
|
||||
private void OnDestroy()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
CancelKVFishing("destroyed");
|
||||
#endif
|
||||
disposables?.Dispose();
|
||||
disposables = null;
|
||||
foreach (var o in _effectDic)
|
||||
|
||||
@@ -522,6 +522,22 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
int isFailCount = 0;
|
||||
// 按了收线按钮
|
||||
public void PiercingFish(int waitingZone)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardDrawing(this)) return;
|
||||
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("strike")) PiercingFishCore(waitingZone);
|
||||
}
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
PiercingFishCore(waitingZone);
|
||||
}
|
||||
void PiercingFishCore(int waitingZone)
|
||||
{
|
||||
//waitingZone==100表示鱼没有开启刺鱼
|
||||
if (!fishingData.IsDrawing)
|
||||
@@ -623,6 +639,9 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
if (piercingToComboB > 0)
|
||||
{
|
||||
await Awaiters.Seconds(damageProtectionTime);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
|
||||
#endif
|
||||
_comboMultiplayer += Random.Range(piercingToComboA, piercingToComboB);
|
||||
//新技能生效 刺鱼触发全力收线进度
|
||||
PublishPerkEvent(RodPerkType.BattlePerk_PiercingToCombo);
|
||||
@@ -632,6 +651,9 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
public async void EternalDeath()
|
||||
{
|
||||
await Awaiters.Seconds(damageProtectionTime);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
|
||||
#endif
|
||||
|
||||
StopAllCoroutines();
|
||||
OnFishingIsOver();
|
||||
@@ -1042,6 +1064,10 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
|
||||
private void Update()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
|
||||
#endif
|
||||
|
||||
if (fishingData.IsDrawing)
|
||||
{
|
||||
Vector3 fishPos = fishingBehavior.GetFishPos();
|
||||
@@ -1219,6 +1245,34 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
}
|
||||
|
||||
void FishEnd()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardEnding(this,fishingData.IsCaughtFish && !isFail)) return;
|
||||
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("ending")) FishEndCore();
|
||||
_=ShowKVRewardPanelAsync();
|
||||
}
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
FishEndCore();
|
||||
}
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
async Task ShowKVRewardPanelAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var panel=await UIManager.Instance.ShowUI(UITypes.FishingRewardPanel);
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardRunCurrent && panel!=null) Destroy(panel);
|
||||
}
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
|
||||
}
|
||||
#endif
|
||||
void FishEndCore()
|
||||
{
|
||||
if (isFail)
|
||||
{
|
||||
@@ -1297,6 +1351,9 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
}
|
||||
else
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return; // Wrapper opens UI after S2 disposal.
|
||||
#endif
|
||||
_ = UIManager.Instance.ShowUI(UITypes.FishingRewardPanel);
|
||||
}
|
||||
}
|
||||
@@ -1746,6 +1803,15 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
//全力收线
|
||||
public async void FullCombo()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
|
||||
try { game.PlayerDataStorage.PlayerKVGameplayLoop.CheckRewardGuide(); }
|
||||
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); return; }
|
||||
}
|
||||
#endif
|
||||
|
||||
if (_isCombo == 1 && curFishingStateName != FishingStateName.UIEating)
|
||||
{
|
||||
if (comboToExDmg > 0 && comboToExDmgCount < curComboToExDmgCount)
|
||||
@@ -1786,6 +1852,9 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
}
|
||||
}
|
||||
await Awaiters.Seconds(0.2f);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
|
||||
#endif
|
||||
lockCombo = false;
|
||||
}
|
||||
}
|
||||
@@ -3130,6 +3199,10 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
}
|
||||
private void OnDisable()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller disabled");
|
||||
#endif
|
||||
|
||||
StopSoundCombo();
|
||||
StopRodAudio();
|
||||
StopNotReeling();
|
||||
@@ -3176,6 +3249,10 @@ public class FishingUpdateSys : MonoBehaviour
|
||||
}
|
||||
private void OnDestroy()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller destroyed");
|
||||
#endif
|
||||
|
||||
disposables?.Dispose();
|
||||
_comboDisposable?.Dispose();
|
||||
_hpcoroutine = null;
|
||||
|
||||
@@ -101,7 +101,7 @@ public class ZZTimeHelper
|
||||
HttpClient client = new HttpClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(5);
|
||||
IConfig config = GContext.container.Resolve<IConfig>();
|
||||
string apiUrl = config.Get<string>(GConstant.K_Event_API_URL, "http://192.168.9.101:5292/");
|
||||
string apiUrl = config.Get<string>(GConstant.K_Event_API_URL, "http://s101.local:18080/");
|
||||
string url = $"{apiUrl}account/svrtime";
|
||||
var svrTimeStr = await client.GetStringAsync(url);
|
||||
// var svrTimeStr = await client.GetStringAsync(timeUrl);
|
||||
|
||||
@@ -27,6 +27,26 @@ namespace Script.RuntimeScript
|
||||
private DiggingGameModel _dataModel;
|
||||
public DiggingGameModel DataModel => _dataModel;
|
||||
|
||||
internal static game.PlayerDataStorage.PlayerKVEmptyModuleState DecodeForKVLoad() =>
|
||||
game.PlayerDataStorage.PlayerKVEmptyModuleState.Instance;
|
||||
internal bool CanResetForKVLoad => _monoBehaviour==null && _digAni==null && _cameraVir==null && _tables==null &&
|
||||
fx_DgSanddig==null && fx_DgSanddighard==null && fx_DgSanddisappear==null && diggingMoveCurve==null &&
|
||||
_broadLogic==null && _broadContainer==null && _gridContianer==null && _gridPropContianer==null &&
|
||||
_propContainer==null && _showPropPositon==null && _explosionManager==null && sanddig_dig_sound==null &&
|
||||
sanddig_dig_audio==null && sanddig_dighard_audio==null && _fxRoot==null && _onCloseAwardPanel==null &&
|
||||
(_dataModel==null || (_dataModel.CurActivityId==0 && _dataModel.digActivityConfig==null && _dataModel.AllClamps.Count==0));
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
if(!CanResetForKVLoad) throw new InvalidOperationException("KV digging module is active.");
|
||||
_dataModel=new DiggingGameModel(); _broadLogic=null; _gameState=SandDigGameState.Normal;
|
||||
IsCanClick=true; ClampIsPass=false; itemDatas=new List<ItemData>(); _awardPanelIsOpen=false;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVEmptyModuleState state)
|
||||
{
|
||||
if(state==null) throw new ArgumentNullException(nameof(state));
|
||||
if(!CanResetForKVLoad || _dataModel==null) throw new InvalidOperationException("KV digging reset required.");
|
||||
}
|
||||
|
||||
private Animation _digAni;
|
||||
|
||||
private CinemachineVirtualCamera _cameraVir;
|
||||
@@ -785,4 +805,4 @@ namespace Script.RuntimeScript
|
||||
Special,
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
8
Client/Assets/Scripts/Services/Account.meta
Normal file
8
Client/Assets/Scripts/Services/Account.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4930c4a417ba4c908416717d26695848
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
224
Client/Assets/Scripts/Services/Account/S101AccountSession.cs
Normal file
224
Client/Assets/Scripts/Services/Account/S101AccountSession.cs
Normal file
@@ -0,0 +1,224 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.Account
|
||||
{
|
||||
public enum AccountLoginError { None, Validation, Http, Rejected, Protocol, Network, Timeout, Cancelled }
|
||||
|
||||
public sealed class AccountLoginResult
|
||||
{
|
||||
public bool Success => Error == AccountLoginError.None;
|
||||
public AccountLoginError Error { get; }
|
||||
public int? StatusCode { get; }
|
||||
internal AccountLoginResult(AccountLoginError error, int? status = null)
|
||||
{ Error = error; StatusCode = status; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal Tuyou-ID authentication, independent of gameplay and PlayFab storage.
|
||||
/// Each login owns a fresh cookie jar; dispose the session on logout.
|
||||
/// </summary>
|
||||
public sealed class S101AccountSession : IDisposable
|
||||
{
|
||||
public const string DefaultApiUrl = "http://s101.local:18080/";
|
||||
readonly object gate = new object();
|
||||
readonly Uri api;
|
||||
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
HttpClient http;
|
||||
int generation;
|
||||
bool disposed;
|
||||
|
||||
public bool IsAuthenticated { get; private set; }
|
||||
public string PlayerId { get; private set; }
|
||||
public uint InternalId { get; private set; }
|
||||
public DateTime CreateTimeUtc { get; private set; }
|
||||
public DateTime ServerTimeUtc { get; private set; }
|
||||
public bool NewlyCreated { get; private set; }
|
||||
public CookieContainer Cookies { get; private set; }
|
||||
public Uri ApiBaseUri => api;
|
||||
|
||||
public S101AccountSession() : this(new Uri(DefaultApiUrl)) { }
|
||||
|
||||
// The factory is optional for transport tests. Returned handlers are owned by this session.
|
||||
public S101AccountSession(Uri api, Func<CookieContainer, HttpMessageHandler> handlerFactory = null)
|
||||
{
|
||||
if (api == null || !api.IsAbsoluteUri ||
|
||||
(api.Scheme != "http" && api.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) ||
|
||||
!string.IsNullOrEmpty(api.UserInfo))
|
||||
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(api));
|
||||
this.api = new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
|
||||
this.handlerFactory = handlerFactory ?? (jar => new HttpClientHandler
|
||||
{
|
||||
CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<AccountLoginResult> LoginAsync(string tuyooId, CancellationToken token = default)
|
||||
{
|
||||
HttpClient client;
|
||||
CookieContainer cookies;
|
||||
int requestGeneration;
|
||||
string id;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return Result(AccountLoginError.Cancelled);
|
||||
generation++;
|
||||
ClearIdentity();
|
||||
http?.Dispose();
|
||||
http = null;
|
||||
Cookies = null;
|
||||
if (string.IsNullOrEmpty(tuyooId) ||
|
||||
!long.TryParse(tuyooId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
|
||||
return Result(AccountLoginError.Validation);
|
||||
if (token.IsCancellationRequested) return Result(AccountLoginError.Cancelled);
|
||||
id = numericId.ToString(CultureInfo.InvariantCulture);
|
||||
cookies = new CookieContainer();
|
||||
client = new HttpClient(handlerFactory(cookies)) { Timeout = TimeSpan.FromSeconds(15) };
|
||||
http = client;
|
||||
Cookies = cookies;
|
||||
requestGeneration = generation;
|
||||
}
|
||||
|
||||
int? status = null;
|
||||
try
|
||||
{
|
||||
using (var request = Request("account/Login", new JObject { ["playerId"] = id }))
|
||||
using (var response = await client.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
|
||||
var body = Parse(await response.Content.ReadAsStringAsync());
|
||||
if (body["state"]?.Type != JTokenType.Integer) return Result(AccountLoginError.Protocol, status);
|
||||
if (body["state"].Value<long>() != 0) return Result(AccountLoginError.Rejected, status);
|
||||
if (body["internalId"]?.Type != JTokenType.Integer ||
|
||||
!uint.TryParse(body["internalId"].ToString(), out var internalId) || internalId == 0 ||
|
||||
body["newCreate"]?.Type != JTokenType.Boolean ||
|
||||
!ReadUtc(body["createTime"], out var created) || !ReadUtc(body["svrTime"], out var serverTime) ||
|
||||
cookies.GetCookies(api).Count == 0)
|
||||
return Result(AccountLoginError.Protocol, status);
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
|
||||
return Result(AccountLoginError.Cancelled, status);
|
||||
PlayerId = id;
|
||||
InternalId = internalId;
|
||||
CreateTimeUtc = created;
|
||||
ServerTimeUtc = serverTime;
|
||||
NewlyCreated = body["newCreate"].Value<bool>();
|
||||
IsAuthenticated = true;
|
||||
}
|
||||
return Result(AccountLoginError.None, status);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
|
||||
}
|
||||
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
|
||||
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
|
||||
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
|
||||
catch (OverflowException) { return Result(AccountLoginError.Protocol, status); }
|
||||
}
|
||||
|
||||
/// <summary>Read-only probe: verifies that the server accepts this session cookie; never writes KV.</summary>
|
||||
public async Task<AccountLoginResult> CheckAuthorizationAsync(CancellationToken token = default)
|
||||
{
|
||||
HttpClient client;
|
||||
int requestGeneration;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || !IsAuthenticated) return Result(AccountLoginError.Rejected);
|
||||
client = http;
|
||||
requestGeneration = generation;
|
||||
}
|
||||
int? status = null;
|
||||
try
|
||||
{
|
||||
using (var request = Request("playerdata/Get", new JObject()))
|
||||
using (var response = await client.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed || generation != requestGeneration || token.IsCancellationRequested)
|
||||
return Result(AccountLoginError.Cancelled, status);
|
||||
if (status == 401 || status == 403) ClearIdentity();
|
||||
}
|
||||
if (response.StatusCode != HttpStatusCode.OK) return Result(AccountLoginError.Http, status);
|
||||
var body = Parse(await response.Content.ReadAsStringAsync());
|
||||
if (!(body["data"] is JObject data)) return Result(AccountLoginError.Protocol, status);
|
||||
foreach (var item in data.Properties())
|
||||
if (item.Value.Type != JTokenType.String) return Result(AccountLoginError.Protocol, status);
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.None, status);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
lock (gate)
|
||||
return Result(disposed || generation != requestGeneration || token.IsCancellationRequested
|
||||
? AccountLoginError.Cancelled : AccountLoginError.Timeout, status);
|
||||
}
|
||||
catch (ObjectDisposedException) { return Result(AccountLoginError.Cancelled, status); }
|
||||
catch (HttpRequestException) { return Result(AccountLoginError.Network, status); }
|
||||
catch (JsonException) { return Result(AccountLoginError.Protocol, status); }
|
||||
}
|
||||
|
||||
HttpRequestMessage Request(string path, JObject body) => new HttpRequestMessage(HttpMethod.Post, new Uri(api, path))
|
||||
{ Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json") };
|
||||
|
||||
static JObject Parse(string text)
|
||||
{
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
var value = JObject.Load(reader);
|
||||
if (reader.Read()) throw new JsonReaderException("Trailing JSON content.");
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
static bool ReadUtc(JToken value, out DateTime date)
|
||||
{
|
||||
date = default;
|
||||
return value?.Type == JTokenType.String && DateTime.TryParse(value.Value<string>(), CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out date);
|
||||
}
|
||||
|
||||
static AccountLoginResult Result(AccountLoginError error, int? status = null) => new AccountLoginResult(error, status);
|
||||
void ClearIdentity()
|
||||
{
|
||||
IsAuthenticated = false;
|
||||
PlayerId = null;
|
||||
InternalId = 0;
|
||||
CreateTimeUtc = default;
|
||||
ServerTimeUtc = default;
|
||||
NewlyCreated = false;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
generation++;
|
||||
ClearIdentity();
|
||||
http?.Dispose();
|
||||
http = null;
|
||||
Cookies = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 748d56d3a1b2489d85e081bfed580314
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -13,6 +13,17 @@ namespace game
|
||||
|
||||
public class AggUserService : PlayFabUserService
|
||||
{
|
||||
public AggUserService() { }
|
||||
internal AggUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability) : base(capability) { }
|
||||
internal IReadOnlyList<PlayerDataStorage.PlayerKVIdentityChannel> KVLinkedAccounts { get; private set; } =
|
||||
Array.Empty<PlayerDataStorage.PlayerKVIdentityChannel>();
|
||||
internal override void ResetForKVLoad()
|
||||
{
|
||||
base.ResetForKVLoad();
|
||||
KVLinkedAccounts=Array.Empty<PlayerDataStorage.PlayerKVIdentityChannel>();
|
||||
}
|
||||
internal override void ReplaceKVLinked(PlayerDataStorage.PlayerKVLoginState state)
|
||||
{ KVLinkedAccounts=new List<PlayerDataStorage.PlayerKVIdentityChannel>(state.Linked).AsReadOnly(); }
|
||||
protected override string NewPlayerName
|
||||
{
|
||||
get
|
||||
@@ -45,6 +56,18 @@ namespace game
|
||||
|
||||
public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
{
|
||||
CustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
|
||||
OnInitData+=InitData;
|
||||
try { return await LoginWithBackend(progress,"kv-loop"); }
|
||||
finally { OnInitData-=InitData; }
|
||||
}
|
||||
#endif
|
||||
var backend=CaptureSaveBackend();
|
||||
if(RejectSaveBackend(backend)) return ELoginStep.Api;
|
||||
var generation = logoutGeneration;
|
||||
if (IsLogin)
|
||||
{
|
||||
progress?.Invoke(3);
|
||||
@@ -58,10 +81,12 @@ namespace game
|
||||
if (!TYSdkFacade.IsLoggedIn)
|
||||
{
|
||||
loginInfo = await TYSdkFacade.Instance.LoginBySns();
|
||||
if (generation != logoutGeneration) return ELoginStep.Sdk;
|
||||
|
||||
if (!loginInfo.isSuccess && tryLoginByToken)
|
||||
{
|
||||
loginInfo = await TYSdkFacade.Instance.LoginByToken();
|
||||
if (generation != logoutGeneration) return ELoginStep.Sdk;
|
||||
}
|
||||
|
||||
if (!loginInfo.isSuccess)
|
||||
@@ -69,7 +94,9 @@ namespace game
|
||||
var evt = new LoginTypeEvt();
|
||||
GContext.Publish(evt);
|
||||
var loginType = await evt.loginType.Task;
|
||||
if (generation != logoutGeneration) return ELoginStep.Sdk;
|
||||
loginInfo = await TYSdkFacade.Instance.Login(loginType);
|
||||
if (generation != logoutGeneration) return ELoginStep.Sdk;
|
||||
}
|
||||
}
|
||||
else
|
||||
@@ -80,21 +107,18 @@ namespace game
|
||||
|
||||
if (!loginInfo.isSuccess) return ELoginStep.Sdk;
|
||||
|
||||
progress.Invoke(1);
|
||||
progress?.Invoke(1);
|
||||
|
||||
CustomId = loginInfo.StrUserId;
|
||||
|
||||
|
||||
OnInitData += InitData;
|
||||
|
||||
var step = await base.Login(step => progress?.Invoke(step + 1));
|
||||
if (step != ELoginStep.Done)
|
||||
{
|
||||
OnInitData -= InitData;
|
||||
return step;
|
||||
}
|
||||
|
||||
OnInitData -= InitData;
|
||||
ELoginStep step;
|
||||
try { step = await LoginWithBackend(value => progress?.Invoke(value + 1),backend); }
|
||||
finally { OnInitData -= InitData; }
|
||||
if (generation != logoutGeneration) return ELoginStep.Sdk;
|
||||
if (step != ELoginStep.Done) return step;
|
||||
|
||||
if (ChannelManager.IsRustoreStore)
|
||||
{
|
||||
@@ -166,8 +190,9 @@ namespace game
|
||||
|
||||
public override void Logout()
|
||||
{
|
||||
TYSdkFacade.Instance.Logout();
|
||||
base.Logout();
|
||||
if(IsControlledKVInstance) return;
|
||||
TYSdkFacade.Instance.Logout();
|
||||
}
|
||||
|
||||
public override async Task<bool> GetLinkState(EAccoutType accoutType)
|
||||
@@ -193,8 +218,25 @@ namespace game
|
||||
return (linkResult.code, linkResult.userId);
|
||||
}
|
||||
|
||||
internal int KVNotificationCount { get; private set; }
|
||||
internal override async Task<PlayerDataStorage.PlayerKVGameLoadResult> LoadKVForValidationAsync(PlayerDataStorage.PlayerKVGameLoadCapability capability)
|
||||
{
|
||||
EnterKVReceiver(ReceiveKVData);
|
||||
try { return await base.LoadKVForValidationAsync(capability); }
|
||||
finally { ExitKVReceiver(); }
|
||||
}
|
||||
void ReceiveKVData(IDictionary<string,string> data)
|
||||
{
|
||||
if(!IsControlledKVInstance || KVLoadingCopy==null || data.Count!=KVLoadingCopy.Count)
|
||||
throw new InvalidOperationException("Controlled notification window invalid.");
|
||||
KVNotificationCount++;
|
||||
}
|
||||
private void InitData(IDictionary<string, string> data)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
// Editor loop has no channel SDK login. M31 already replaced account URLs and linked flags.
|
||||
if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
|
||||
#endif
|
||||
if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1")
|
||||
{
|
||||
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle);
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game
|
||||
{
|
||||
@@ -47,6 +48,27 @@ namespace game
|
||||
public DateTime? signTime { get; private set; }
|
||||
|
||||
LureDonateData lureDonateData;
|
||||
|
||||
// KV loading owns these three fields only; membership, transport and chat lifecycle stay untouched.
|
||||
internal static PlayerDataStorage.PlayerKVClubState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
|
||||
PlayerDataStorage.PlayerKVSchemaSnapshot snapshot, PlayerDataStorage.PlayerKVSchemaDiagnostics errors) =>
|
||||
PlayerDataStorage.PlayerKVSaveSchema.DecodeClub(raw,snapshot,errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
signTime = null;
|
||||
lureDonateData = new LureDonateData();
|
||||
IsOpenClubRed = true;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVClubState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
var donation = new LureDonateData { DonateCountTime=state.DonateTime,
|
||||
ClubLureDonateCount=state.DonateCount, ClubLureRequestCDEnd=state.RequestEnd };
|
||||
signTime = state.SignTime; lureDonateData = donation; IsOpenClubRed = state.OpenRed;
|
||||
}
|
||||
internal PlayerDataStorage.PlayerKVClubState SnapshotForKVValidation() =>
|
||||
new PlayerDataStorage.PlayerKVClubState(signTime,lureDonateData.DonateCountTime,
|
||||
lureDonateData.ClubLureDonateCount,lureDonateData.ClubLureRequestCDEnd,IsOpenClubRed);
|
||||
public int ClubLureDonateCount => lureDonateData.ClubLureDonateCount;
|
||||
public DateTime ClubLureRequestCDEnd => lureDonateData.ClubLureRequestCDEnd;
|
||||
|
||||
@@ -154,28 +176,37 @@ namespace game
|
||||
}
|
||||
public async void GetSelfClub()
|
||||
{
|
||||
if (!userService.IsAuthorized || string.IsNullOrEmpty(userService.ApiPlayerId))
|
||||
return;
|
||||
var sessionVersion = customServerMgr.SessionVersion;
|
||||
GetSelfClubRequest getSelfClubRequest = new GetSelfClubRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
playerId = userService.ApiPlayerId,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("GetSelfClub", getSelfClubRequest);
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
if (sessionVersion != customServerMgr.SessionVersion) return;
|
||||
if (string.IsNullOrWhiteSpace(json)) return;
|
||||
GetSelfClubResp response;
|
||||
try
|
||||
{
|
||||
GetSelfClubResp getSelfClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetSelfClubResp>(json);
|
||||
myClubInfo = getSelfClubResp.clubInfo;
|
||||
if (myClubInfo != null)
|
||||
{
|
||||
memberRole = getSelfClubResp.role;
|
||||
//signTime = getSelfClubResp.signTime;
|
||||
internalIdToPlayerId = getSelfClubResp.playerIDs;
|
||||
SetClubPlayerProfile(getSelfClubResp.clubPlayerProfiles);
|
||||
chatService.GetConversationList((long)myClubInfo.id);
|
||||
}
|
||||
var body = JObject.Parse(json);
|
||||
if (!body.TryGetValue("clubInfo", out var club) ||
|
||||
(club.Type != JTokenType.Null && club.Type != JTokenType.Object)) return;
|
||||
response = body.ToObject<GetSelfClubResp>();
|
||||
if (response == null) return;
|
||||
if (club.Type == JTokenType.Object &&
|
||||
(response.clubInfo == null || response.clubInfo.id == 0 ||
|
||||
body["role"]?.Type != JTokenType.Integer || !Enum.IsDefined(typeof(EMemberRole), response.role))) return;
|
||||
}
|
||||
else
|
||||
catch (Newtonsoft.Json.JsonException) { return; }
|
||||
catch (OverflowException) { return; }
|
||||
myClubInfo = response.clubInfo;
|
||||
memberRole = myClubInfo == null ? EMemberRole.Member : response.role;
|
||||
internalIdToPlayerId = response.playerIDs ?? new Dictionary<long, string>();
|
||||
if (myClubInfo != null)
|
||||
{
|
||||
memberRole = EMemberRole.Member;
|
||||
myClubInfo = null;
|
||||
SetClubPlayerProfile(response.clubPlayerProfiles);
|
||||
chatService.GetConversationList((long)myClubInfo.id);
|
||||
}
|
||||
SetClubRed();
|
||||
MyClubInfoChange();
|
||||
@@ -218,16 +249,48 @@ namespace game
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsDecimalPlayerId(string id) =>
|
||||
long.TryParse(id, System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var number) && number > 0;
|
||||
|
||||
private bool TryGetClubSession(out string playerId, out int version)
|
||||
{
|
||||
playerId = userService.ApiPlayerId;
|
||||
version = customServerMgr.SessionVersion;
|
||||
return userService.IsAuthorized && IsDecimalPlayerId(playerId);
|
||||
}
|
||||
|
||||
private bool IsClubSessionCurrent(string playerId, int version) =>
|
||||
version == customServerMgr.SessionVersion && userService.IsAuthorized && playerId == userService.ApiPlayerId;
|
||||
|
||||
private static bool TryReadClubResponse<T>(string json, out T result, params int[] acceptedCodes) where T : class
|
||||
{
|
||||
result = null;
|
||||
if (string.IsNullOrWhiteSpace(json)) return false;
|
||||
try
|
||||
{
|
||||
var body = Newtonsoft.Json.Linq.JObject.Parse(json);
|
||||
var code = body["code"];
|
||||
if (code?.Type != Newtonsoft.Json.Linq.JTokenType.Integer || !acceptedCodes.Contains(code.Value<int>())) return false;
|
||||
result = body.ToObject<T>();
|
||||
return result != null;
|
||||
}
|
||||
catch (Newtonsoft.Json.JsonException) { return false; }
|
||||
catch (OverflowException) { return false; }
|
||||
}
|
||||
|
||||
public async Task<List<SocialInfoItemData>> GetClubDetails(ulong clubID, bool applyState = false)
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null;
|
||||
ApplyState = false;
|
||||
GetClubDetailsRequest getClubDetailsRequest = new GetClubDetailsRequest()
|
||||
{
|
||||
id = clubID,
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
applyState = applyState,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("GetClubDetails", getClubDetailsRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
GetClubDetailsResp clubDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<GetClubDetailsResp>(json);
|
||||
@@ -243,6 +306,7 @@ namespace game
|
||||
}
|
||||
List<long> longs1 = clubDetails.playerIDs.Keys.ToList();
|
||||
HashSet<long> longs = await chatService.GetOnlineUsers(longs1);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
|
||||
List<ClubPlayerProfile> clubPlayerProfiles = clubDetails.clubPlayerProfiles;
|
||||
SetClubPlayerProfile(clubPlayerProfiles);
|
||||
|
||||
@@ -284,40 +348,27 @@ namespace game
|
||||
|
||||
public async Task<ClubInfo> CreateClub(ClubSettingData clubSettingData)
|
||||
{
|
||||
CreateClubRequest createClubRequest = new CreateClubRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
//entityKey = userService.entityKey.Id,
|
||||
clubSettingData = clubSettingData,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("CreateClub", createClubRequest);
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
CreateClubResp createClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<CreateClubResp>(json);
|
||||
if (createClubResp.code != 2)
|
||||
{
|
||||
if (createClubResp.code == 0)
|
||||
{
|
||||
memberRole = EMemberRole.Owner;
|
||||
ShowPromote();
|
||||
}
|
||||
myClubInfo = createClubResp.clubInfo;
|
||||
chatService.GetConversationList((long)myClubInfo.id);
|
||||
internalIdToPlayerId[userService.InternalId] = userService.UserId;
|
||||
MyClubInfoChange(true);
|
||||
return myClubInfo;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
myClubInfo = null;
|
||||
}
|
||||
MyClubInfoChange();
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return null;
|
||||
var internalId = userService.InternalId;
|
||||
var request = new CreateClubRequest { playerId = apiPlayerId, clubSettingData = clubSettingData };
|
||||
var json = await customServerMgr.ClubRequest("CreateClub", request);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return null;
|
||||
// Code 1 means already a member; returning a club would charge the create UI again.
|
||||
if (!TryReadClubResponse<CreateClubResp>(json, out var response, 0) || response.clubInfo == null || response.clubInfo.id == 0)
|
||||
return null;
|
||||
memberRole = EMemberRole.Owner;
|
||||
myClubInfo = response.clubInfo;
|
||||
internalIdToPlayerId[internalId] = apiPlayerId;
|
||||
ShowPromote();
|
||||
chatService.GetConversationList((long)myClubInfo.id);
|
||||
MyClubInfoChange(true);
|
||||
return myClubInfo;
|
||||
}
|
||||
public async void ShowPromote()
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
GameObject go = await UIManager.Instance.ShowUI(UITypes.ClubPromoteNoticePopupPanel);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion) || go == null) return;
|
||||
var ClubPromoteNoticePopupPanel = go.GetComponent<ClubPromoteNoticePopupPanel>();
|
||||
ClubPromoteNoticePopupPanel.SetRole(memberRole);
|
||||
}
|
||||
@@ -344,19 +395,21 @@ namespace game
|
||||
|
||||
public async Task<bool> ApplyClub(ulong clubID)
|
||||
{
|
||||
memberRole = EMemberRole.Member;
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return false;
|
||||
ApplyClubRequest applyClubRequest = new ApplyClubRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
//entityKey = userService.entityKey.Id,
|
||||
id = clubID,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("ApplyClub", applyClubRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return false;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
ApplyClubResp applyClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ApplyClubResp>(json);
|
||||
if (applyClubResp.code == 0)
|
||||
{
|
||||
memberRole = EMemberRole.Member;
|
||||
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_41"));
|
||||
myClubInfo = applyClubResp.clubInfo;
|
||||
internalIdToPlayerId = applyClubResp.playerIDs;
|
||||
@@ -380,6 +433,10 @@ namespace game
|
||||
|
||||
public async void HandleApply(bool isAgree, string applyId)
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (applyId == "" && isAgree) return;
|
||||
if (applyId != "" && !IsDecimalPlayerId(applyId)) return;
|
||||
if (myClubInfo == null) return;
|
||||
if (isAgree && myClubInfo.curMembers >= myClubInfo.maxMembers)
|
||||
{
|
||||
//人数已满
|
||||
@@ -392,31 +449,32 @@ namespace game
|
||||
isAgree = isAgree,
|
||||
id = myClubInfo.id,
|
||||
};
|
||||
if (applyId == "")
|
||||
{
|
||||
clubPlayerInfos.Clear();
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < clubPlayerInfos.Count; i++)
|
||||
{
|
||||
if (clubPlayerInfos[i].playFabId == applyId)
|
||||
{
|
||||
clubPlayerInfos.RemoveAt(i);
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0
|
||||
&& CanAccepted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
string json = await customServerMgr.ClubRequest("HandleApply", handleApplyRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
HandleApplyResp handleApplyResp = Newtonsoft.Json.JsonConvert.DeserializeObject<HandleApplyResp>(json);
|
||||
if (handleApplyResp.code == 0)
|
||||
if (!TryReadClubResponse<HandleApplyResp>(json, out var handleApplyResp, applyId == "" ? 4 : 0) || myClubInfo == null) return;
|
||||
if (handleApplyResp.code == 0 || handleApplyResp.code == 4)
|
||||
{
|
||||
myClubInfo.curMembers++;
|
||||
if (applyId == "")
|
||||
{
|
||||
clubPlayerInfos.Clear();
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false);
|
||||
}
|
||||
else
|
||||
{
|
||||
for (int i = 0; i < clubPlayerInfos.Count; i++)
|
||||
{
|
||||
if (clubPlayerInfos[i].playFabId == applyId)
|
||||
{
|
||||
clubPlayerInfos.RemoveAt(i);
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, clubPlayerInfos.Count > 0
|
||||
&& CanAccepted);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isAgree) myClubInfo.curMembers++;
|
||||
GContext.Publish(new ClubSttingUpdateEvent());
|
||||
}
|
||||
}
|
||||
@@ -424,17 +482,19 @@ namespace game
|
||||
|
||||
public async void GetApplyList()
|
||||
{
|
||||
clubPlayerInfos = new List<SocialInfoItemData>();
|
||||
RedPointManager.Instance.SetRedPointState(RedPointName.ApplyList, false);
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (myClubInfo == null) return;
|
||||
GetApplyListRequest getApplyListRequest = new GetApplyListRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
id = myClubInfo.id,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("GetApplyList", getApplyListRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
GetApplyListResp getApplyListResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetApplyListResp>(json);
|
||||
clubPlayerInfos = new List<SocialInfoItemData>();
|
||||
SetClubPlayerProfile(getApplyListResp.clubPlayerProfiles);
|
||||
for (int i = 0; i < getApplyListResp.userIDList.Count; i++)
|
||||
{
|
||||
@@ -467,16 +527,12 @@ namespace game
|
||||
}
|
||||
public async void QuitClub(string appointId = "0")
|
||||
{
|
||||
QuitClubRequest quitClubRequest = new QuitClubRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
appointId = appointId,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("QuitClub", quitClubRequest);
|
||||
//if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
//{
|
||||
// QuitClubResp quitClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<QuitClubResp>(json);
|
||||
//}
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (!string.IsNullOrEmpty(appointId) && appointId != "0" && (!IsDecimalPlayerId(appointId) || appointId == apiPlayerId)) return;
|
||||
var request = new QuitClubRequest { playerId = apiPlayerId, appointId = appointId };
|
||||
var json = await customServerMgr.ClubRequest("QuitClub", request);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!TryReadClubResponse<QuitClubResp>(json, out var response, 0, 1)) return;
|
||||
chatService.OnQuit();
|
||||
myClubInfo = null;
|
||||
ClearLikeCursorIds();
|
||||
@@ -485,36 +541,49 @@ namespace game
|
||||
}
|
||||
public async void Demote(string playerId)
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
|
||||
if (myClubInfo == null) return;
|
||||
DemoteClubRequest demoteClubRequest = new DemoteClubRequest()
|
||||
{
|
||||
culbId = myClubInfo.id,
|
||||
demoteId = playerId,
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("DemoteClub", demoteClubRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
}
|
||||
public async void Promote(string playerId, EMemberRole role)
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
|
||||
if (myClubInfo == null) return;
|
||||
PromoteClubRequest promoteClubRequest = new PromoteClubRequest()
|
||||
{
|
||||
culbId = myClubInfo.id,
|
||||
promoteId = playerId,
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
role = role,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("PromoteClub", promoteClubRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
}
|
||||
public async void Kickout(string playerId)
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (!IsDecimalPlayerId(playerId) || playerId == apiPlayerId) return;
|
||||
if (myClubInfo == null) return;
|
||||
KickoutClubRequest kickoutClubRequest = new KickoutClubRequest()
|
||||
{
|
||||
culbId = myClubInfo.id,
|
||||
kickoutId = playerId,
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
};
|
||||
myClubInfo.curMembers--;
|
||||
GContext.Publish(new ClubSttingUpdateEvent());
|
||||
string json = await customServerMgr.ClubRequest("KickoutClub", kickoutClubRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!TryReadClubResponse<KickoutClubResp>(json, out var response, 0) || myClubInfo == null) return;
|
||||
myClubInfo.curMembers = Math.Max(0, myClubInfo.curMembers - 1);
|
||||
GContext.Publish(new ClubSttingUpdateEvent());
|
||||
//if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
//{
|
||||
// KickoutClubResp quitClubResp = Newtonsoft.Json.JsonConvert.DeserializeObject<KickoutClubResp>(json);
|
||||
@@ -522,52 +591,67 @@ namespace game
|
||||
}
|
||||
public async void ReadClubEvent()
|
||||
{
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
ReadClubEventRequest readClubEventRequest = new ReadClubEventRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("ReadClubEvent", readClubEventRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
//if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
//{
|
||||
// ReadClubEventResp readClubEventResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ReadClubEventResp>(json);
|
||||
//}
|
||||
}
|
||||
public async void ClubSingin()
|
||||
private int? signingSession;
|
||||
public async Task ClubSingin()
|
||||
{
|
||||
signTime = ZZTimeHelper.UtcNow().UtcNowOffset();
|
||||
ClubSinginRequest clubSinginRequest = new ClubSinginRequest()
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
if (myClubInfo == null) return;
|
||||
if (signingSession == sessionVersion || signTime?.Date == ZZTimeHelper.UtcNow().UtcNowOffset().Date) return;
|
||||
signingSession = sessionVersion;
|
||||
try
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
id = myClubInfo.id,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("ClubSingin", clubSinginRequest);
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
ClubSinginResp clubSinginResp = Newtonsoft.Json.JsonConvert.DeserializeObject<ClubSinginResp>(json);
|
||||
signTime = ZZTimeHelper.UtcNow().UtcNowOffset();
|
||||
PlayFabMgr.Instance.UpdateUserDataValue("ClubSignin", signTime.Value.ToString("yyyy-MM-dd HH:mm:ss"));
|
||||
if (clubSinginResp.code == 0)
|
||||
ClubSinginRequest clubSinginRequest = new ClubSinginRequest()
|
||||
{
|
||||
chatService.SendSystemMessage($"{MessageChatType.SI}_{userService.UserId}");
|
||||
//签到成功奖励
|
||||
GContext.container.Resolve<PlayerItemData>().AddItemByDrop(GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward, null);
|
||||
GContext.Publish(new ShowData());
|
||||
#if AGG
|
||||
using (var e = GEvent.GameEvent("guild_sign"))
|
||||
playerId = apiPlayerId,
|
||||
id = myClubInfo.id,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("ClubSingin", clubSinginRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
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"));
|
||||
if (clubSinginResp.code == 0)
|
||||
{
|
||||
e.AddContent("guild_id", myClubInfo.id)
|
||||
.AddContent("drop_list", GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward);
|
||||
chatService.SendSystemMessage($"{MessageChatType.SI}_{apiPlayerId}");
|
||||
//签到成功奖励
|
||||
GContext.container.Resolve<PlayerItemData>().AddItemByDrop(GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward, null);
|
||||
GContext.Publish(new ShowData());
|
||||
#if AGG
|
||||
using (var e = GEvent.GameEvent("guild_sign"))
|
||||
{
|
||||
e.AddContent("guild_id", myClubInfo.id)
|
||||
.AddContent("drop_list", GContext.container.Resolve<cfg.Tables>().TbServerConfig.ClubSignReward);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
}
|
||||
SetClubRed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (signingSession == sessionVersion) signingSession = null;
|
||||
}
|
||||
SetClubRed();
|
||||
}
|
||||
|
||||
|
||||
public void SendSystemMessage2(int index)
|
||||
{
|
||||
chatService.SendSystemMessage2($"{userService.UserId}_{index}");
|
||||
if (!TryGetClubSession(out var apiPlayerId, out _)) return;
|
||||
chatService.SendSystemMessage2($"{apiPlayerId}_{index}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -583,15 +667,17 @@ namespace game
|
||||
}
|
||||
public async void GetServiceEvent()
|
||||
{
|
||||
energyAddCount = 0;
|
||||
if (!TryGetClubSession(out var apiPlayerId, out var sessionVersion)) return;
|
||||
GetPictureRequest getPictureRequest = new GetPictureRequest()
|
||||
{
|
||||
playerId = userService.UserId,
|
||||
playerId = apiPlayerId,
|
||||
};
|
||||
string json = await customServerMgr.ClubRequest("GetPictureList", getPictureRequest);
|
||||
if (!IsClubSessionCurrent(apiPlayerId, sessionVersion)) return;
|
||||
if (!string.IsNullOrEmpty(json) && json != "[]")
|
||||
{
|
||||
GetPictureResp getPictureResp = Newtonsoft.Json.JsonConvert.DeserializeObject<GetPictureResp>(json);
|
||||
energyAddCount = 0;
|
||||
//var pictureDatas = getPictureResp.pictureDataList;
|
||||
//if (pictureDatas != null && pictureDatas.Count > 0)
|
||||
//{
|
||||
|
||||
@@ -12,6 +12,24 @@ public interface IFootPrintService
|
||||
public class FootPrintService : IFootPrintService
|
||||
{
|
||||
private EFootPrint _footPrint;
|
||||
private ulong _kvCompleteFootPrint;
|
||||
internal ulong CompleteFootPrintForKVValidation => _kvCompleteFootPrint;
|
||||
internal static game.PlayerDataStorage.PlayerKVFootPrintState DecodeForKVLoad(
|
||||
System.Collections.Generic.IReadOnlyDictionary<string, string> raw,
|
||||
game.PlayerDataStorage.PlayerKVSchemaDiagnostics errors)
|
||||
=> game.PlayerDataStorage.PlayerKVSaveSchema.DecodeFootPrint(raw, errors);
|
||||
internal void ResetForKVLoad()
|
||||
{
|
||||
_kvCompleteFootPrint = 0;
|
||||
_footPrint = 0;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(game.PlayerDataStorage.PlayerKVFootPrintState state)
|
||||
{
|
||||
if (state == null) throw new ArgumentNullException(nameof(state));
|
||||
_kvCompleteFootPrint = state.Bits;
|
||||
// This legacy view contains only the low 32 bits. The ulong field retains the complete KV state.
|
||||
_footPrint = unchecked((EFootPrint)(int)(uint)state.Bits);
|
||||
}
|
||||
public EFootPrint FootPrint => _footPrint;
|
||||
public void UpdateFootPrint(EFootPrint newState)
|
||||
{
|
||||
|
||||
8
Client/Assets/Scripts/Services/PlayerData.meta
Normal file
8
Client/Assets/Scripts/Services/PlayerData.meta
Normal file
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 761582591ce048769d522f8410c647cc
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
105
Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs
Normal file
105
Client/Assets/Scripts/Services/PlayerData/PlayerKVCacheStore.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Internal seam for deterministic disk-failure tests; no production callback is supplied.
|
||||
internal enum KVCacheWriteStage { BeforeWrite, AfterWrite, AfterFlush, BeforeReplace, AfterReplace }
|
||||
|
||||
internal sealed class PlayerKVCacheStore : IDisposable
|
||||
{
|
||||
const long MaxBytes = 64L * 1024 * 1024;
|
||||
readonly KVIdentity identity;
|
||||
readonly FileStream lease;
|
||||
readonly Action<KVCacheWriteStage> fault;
|
||||
readonly string main, temporary, backup;
|
||||
bool disposed;
|
||||
|
||||
PlayerKVCacheStore(KVIdentity identity, FileStream lease, Action<KVCacheWriteStage> fault)
|
||||
{
|
||||
this.identity = identity; this.lease = lease; this.fault = fault;
|
||||
main = Path.Combine(identity.DirectoryPath, "state.json");
|
||||
temporary = main + ".tmp"; backup = main + ".bak";
|
||||
}
|
||||
|
||||
internal static PlayerKVOpenError Open(KVIdentity identity, Action<KVCacheWriteStage> fault,
|
||||
out PlayerKVCacheStore store, out KVSnapshot snapshot)
|
||||
{
|
||||
store = null; snapshot = null;
|
||||
FileStream lease = null;
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(identity.DirectoryPath);
|
||||
try { lease = new FileStream(Path.Combine(identity.DirectoryPath, "state.lock"), FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); }
|
||||
catch (IOException ex) when ((ex.HResult & 0xffff) == 32 || (ex.HResult & 0xffff) == 33)
|
||||
{ return PlayerKVOpenError.Busy; }
|
||||
store = new PlayerKVCacheStore(identity, lease, fault);
|
||||
if (!File.Exists(store.main))
|
||||
{
|
||||
if (File.Exists(store.backup) || File.Exists(store.temporary)) throw KVSnapshot.Invalid();
|
||||
snapshot = new KVSnapshot();
|
||||
}
|
||||
else
|
||||
{
|
||||
if (new FileInfo(store.main).Length > MaxBytes) throw KVSnapshot.Invalid();
|
||||
snapshot = KVSnapshot.Decode(File.ReadAllText(store.main, new UTF8Encoding(false, true)), identity);
|
||||
}
|
||||
return PlayerKVOpenError.None;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException || ex is OverflowException || ex is FormatException || ex is DecoderFallbackException)
|
||||
{
|
||||
store?.Dispose(); lease?.Dispose(); store = null;
|
||||
return PlayerKVOpenError.RecoveryRequired;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex))
|
||||
{
|
||||
store?.Dispose(); lease?.Dispose(); store = null;
|
||||
return PlayerKVOpenError.StorageError;
|
||||
}
|
||||
}
|
||||
|
||||
internal bool Save(KVSnapshot snapshot, out bool mayHaveCommitted)
|
||||
{
|
||||
mayHaveCommitted = false;
|
||||
try
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(PlayerKVCacheStore));
|
||||
snapshot.Validate();
|
||||
var bytes = new UTF8Encoding(false, true).GetBytes(snapshot.Encode(identity));
|
||||
if (bytes.LongLength > MaxBytes) throw new IOException("KV cache capacity exceeded.");
|
||||
fault?.Invoke(KVCacheWriteStage.BeforeWrite);
|
||||
using (var stream = new FileStream(temporary, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterWrite);
|
||||
stream.Flush(true);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterFlush);
|
||||
}
|
||||
fault?.Invoke(KVCacheWriteStage.BeforeReplace);
|
||||
mayHaveCommitted = true;
|
||||
// The temp file is on the same filesystem. Never delete the main file first.
|
||||
if (File.Exists(main)) File.Replace(temporary, main, backup);
|
||||
else File.Move(temporary, main);
|
||||
fault?.Invoke(KVCacheWriteStage.AfterReplace);
|
||||
mayHaveCommitted = false;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex) when (DiskError(ex) || ex is JsonException || ex is EncoderFallbackException)
|
||||
{ return false; }
|
||||
}
|
||||
|
||||
static bool DiskError(Exception ex) => ex is IOException || ex is UnauthorizedAccessException ||
|
||||
ex is SecurityException || ex is NotSupportedException || ex is ObjectDisposedException;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
lease.Dispose();
|
||||
// Keep files, including failed temporary writes, for explicit recovery diagnosis.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb1e30eab9b24686a5b5110bc68c19b6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
144
Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs
Normal file
144
Client/Assets/Scripts/Services/PlayerData/PlayerKVClient.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Private KV protocol for one fixed authenticated account/environment.
|
||||
/// Caller owns the HttpClient, cookies and cancellation lifetime; never reuse
|
||||
/// that transport for another account. This class neither retries nor logs payloads.
|
||||
/// Production login/storage integration is deliberately outside this adapter.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVClient
|
||||
{
|
||||
readonly HttpClient http;
|
||||
readonly Uri api;
|
||||
|
||||
public PlayerKVClient(HttpClient http, Uri apiBaseUri)
|
||||
{
|
||||
this.http = http ?? throw new ArgumentNullException(nameof(http));
|
||||
if (apiBaseUri == null || !apiBaseUri.IsAbsoluteUri ||
|
||||
(apiBaseUri.Scheme != "http" && apiBaseUri.Scheme != "https") ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.UserInfo) ||
|
||||
!string.IsNullOrEmpty(apiBaseUri.Query) || !string.IsNullOrEmpty(apiBaseUri.Fragment))
|
||||
throw new ArgumentException("An absolute HTTP(S) API base URI is required.", nameof(apiBaseUri));
|
||||
api = new Uri(apiBaseUri.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> GetAsync(IEnumerable<string> keys = null, CancellationToken token = default)
|
||||
{
|
||||
if (!CopyKeys(keys, true, out var snapshot)) return Invalid();
|
||||
var body = new JObject();
|
||||
if (snapshot.Count > 0) body["keys"] = new JArray(snapshot);
|
||||
return SendAsync("Get", body, false, token);
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> SetAsync(IDictionary<string, string> data, CancellationToken token = default)
|
||||
{
|
||||
if (data == null) return Invalid();
|
||||
var snapshot = new JObject();
|
||||
foreach (var pair in data)
|
||||
{
|
||||
if (!ValidKey(pair.Key) || pair.Value == null) return Invalid();
|
||||
snapshot.Add(pair.Key, pair.Value);
|
||||
}
|
||||
return SendAsync("Set", new JObject { ["data"] = snapshot }, true, token);
|
||||
}
|
||||
|
||||
public Task<PlayerKVResult> DeleteAsync(IEnumerable<string> keys, CancellationToken token = default)
|
||||
{
|
||||
if (!CopyKeys(keys, false, out var snapshot)) return Invalid();
|
||||
return SendAsync("Delete", new JObject { ["keys"] = new JArray(snapshot) }, true, token);
|
||||
}
|
||||
|
||||
static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
static bool CopyKeys(IEnumerable<string> keys, bool allowNull, out List<string> snapshot)
|
||||
{
|
||||
snapshot = new List<string>();
|
||||
if (keys == null) return allowNull;
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (!ValidKey(key)) return false;
|
||||
snapshot.Add(key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static Task<PlayerKVResult> Invalid() => Task.FromResult(new PlayerKVResult(PlayerKVError.Validation));
|
||||
|
||||
async Task<PlayerKVResult> SendAsync(string action, JObject body, bool mutation, CancellationToken token)
|
||||
{
|
||||
if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
int? status = null;
|
||||
bool enteredTransport = false;
|
||||
try
|
||||
{
|
||||
using (var request = new HttpRequestMessage(HttpMethod.Post, new Uri(api, "playerdata/" + action)))
|
||||
{
|
||||
request.Content = new StringContent(body.ToString(Formatting.None), Encoding.UTF8, "application/json");
|
||||
token.ThrowIfCancellationRequested();
|
||||
enteredTransport = true;
|
||||
using (var response = await http.SendAsync(request, token))
|
||||
{
|
||||
status = (int)response.StatusCode;
|
||||
token.ThrowIfCancellationRequested();
|
||||
if (response.StatusCode != HttpStatusCode.OK)
|
||||
return new PlayerKVResult(status == 401 || status == 403
|
||||
? PlayerKVError.Unauthorized : PlayerKVError.Http, status,
|
||||
mayHaveCommitted: mutation);
|
||||
if (mutation) return new PlayerKVResult(PlayerKVError.None, status);
|
||||
var text = await response.Content.ReadAsStringAsync();
|
||||
token.ThrowIfCancellationRequested();
|
||||
return new PlayerKVResult(PlayerKVError.None, status, ParseData(text));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new PlayerKVResult(token.IsCancellationRequested ? PlayerKVError.Cancelled : PlayerKVError.Timeout,
|
||||
status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Cancelled, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Network, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return new PlayerKVResult(PlayerKVError.Protocol, status, mayHaveCommitted: mutation && enteredTransport);
|
||||
}
|
||||
}
|
||||
|
||||
static Dictionary<string, string> ParseData(string text)
|
||||
{
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
var body = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
|
||||
if (reader.Read() || !(body["data"] is JObject data)) throw new JsonReaderException("Invalid KV response.");
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in data.Properties())
|
||||
{
|
||||
if (property.Value.Type != JTokenType.String) throw new JsonReaderException("KV value must be a string.");
|
||||
result.Add(property.Name, property.Value.Value<string>());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bcdbe8122e0b4f9eb82d2d1579081a7d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// The lease-only capability remains source-compatible with the independent 4B1 regression host.
|
||||
// This separate capability supplies every dependency required by a complete controlled load.
|
||||
internal sealed class PlayerKVGameLoadCapability
|
||||
{
|
||||
internal PlayerKVIdentitySnapshot Identity { get; }
|
||||
internal PlayerKVSchemaSnapshot Snapshot { get; }
|
||||
internal Uri Api { get; }
|
||||
internal string CacheRoot { get; }
|
||||
internal int LoginObserveMs { get; }
|
||||
internal int QuitObserveMs { get; }
|
||||
internal Func<int,CancellationToken,Task> Delay { get; }
|
||||
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
|
||||
readonly PlayerKVControlledLoadCapability auth,kv,lease;
|
||||
readonly object gate=new object();
|
||||
CookieContainer cookies;
|
||||
HttpMessageHandler pendingKV,pendingLease;
|
||||
Exception cleanupFailure;
|
||||
readonly System.Collections.Generic.List<HttpMessageHandler> failedHandlers=new System.Collections.Generic.List<HttpMessageHandler>();
|
||||
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)
|
||||
{
|
||||
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) ||
|
||||
!string.IsNullOrEmpty(api.Query) || !string.IsNullOrEmpty(api.Fragment) || string.IsNullOrEmpty(cacheRoot) ||
|
||||
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;
|
||||
}
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVGameLoadCapability CreateForValidation(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
|
||||
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
|
||||
Func<CookieContainer,HttpMessageHandler> lease,int loginMs=10000,int quitMs=5000,Func<int,CancellationToken,Task> delay=null)
|
||||
{
|
||||
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,loginMs,quitMs,delay,
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(auth),PlayerKVControlledLoadCapability.CreateForValidation(kv),
|
||||
PlayerKVControlledLoadCapability.CreateForValidation(lease));
|
||||
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
|
||||
return capability;
|
||||
}
|
||||
#endif
|
||||
internal PlayerKVLoginContext CreateContext(string id,long epoch,Func<long,bool> current)
|
||||
{
|
||||
lock(gate) { if(claimed) throw new InvalidOperationException("Controlled capability already used."); claimed=true; }
|
||||
return new PlayerKVLoginContext(id,CacheRoot,epoch,current,Api,
|
||||
()=>new S101AccountSession(Api,CreateAuthentication),()=>new PlayerKVGameSessionBridge(TakeKV));
|
||||
}
|
||||
HttpMessageHandler CreateAuthentication(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(cookies!=null) throw new InvalidOperationException("Repeated authentication factory.");
|
||||
cookies=jar; HttpMessageHandler authentication=null;
|
||||
try
|
||||
{
|
||||
// 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))
|
||||
throw new InvalidOperationException("Distinct transport ownership required.");
|
||||
return authentication;
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { authentication?.Dispose(); } catch(Exception ex) { cleanupFailure=ex; failedHandlers.Add(authentication); }
|
||||
try { CloseUnused(); } catch { }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
HttpMessageHandler TakeKV(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
{ if(!ReferenceEquals(jar,cookies) || pendingKV==null) throw new InvalidOperationException("KV transport ownership invalid.");
|
||||
var result=pendingKV; pendingKV=null; return result; }
|
||||
}
|
||||
HttpMessageHandler TakeLease(CookieContainer jar)
|
||||
{
|
||||
lock(gate)
|
||||
{ if(!ReferenceEquals(jar,cookies) || pendingLease==null) throw new InvalidOperationException("Lease transport ownership invalid.");
|
||||
var result=pendingLease; pendingLease=null; return result; }
|
||||
}
|
||||
internal void CloseUnused()
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
// Attempt every owned cleanup once; retain a failure even if a later Dispose succeeds.
|
||||
var ownedLease=pendingLease; pendingLease=null;
|
||||
var ownedKV=pendingKV; pendingKV=null;
|
||||
try { ownedLease?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedLease); }
|
||||
try { ownedKV?.Dispose(); } catch(Exception ex) { cleanupFailure=cleanupFailure ?? ex; failedHandlers.Add(ownedKV); }
|
||||
if(cleanupFailure!=null) throw new InvalidOperationException("Controlled transport cleanup failed.",cleanupFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 89934f8eb7b441bcb60409e83a018b76
|
||||
969
Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs
Normal file
969
Client/Assets/Scripts/Services/PlayerData/PlayerKVDecodedSave.cs
Normal file
@@ -0,0 +1,969 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVSchemaVerdict { Valid, EmptyNew, EmptyExisting, MissingCore, Malformed, ConflictingRepresentations, UnsupportedSchema, Stale }
|
||||
internal sealed class PlayerKVProgressPackPair
|
||||
{
|
||||
internal PlayerKVProgressPackState Current { get; }
|
||||
internal PlayerKVProgressPackState Migration { get; }
|
||||
internal PlayerKVProgressPackPair(PlayerKVProgressPackState current,PlayerKVProgressPackState migration)
|
||||
{ Current=current; Migration=migration; }
|
||||
}
|
||||
internal sealed class PlayerKVDecodedSave
|
||||
{
|
||||
readonly object[] modules;
|
||||
internal string PlayerId { get; }
|
||||
internal long Epoch { get; }
|
||||
internal string ConfigurationVersion { get; }
|
||||
internal IReadOnlyDictionary<string,string> Raw { get; }
|
||||
internal int ModuleCount => modules.Length;
|
||||
internal int UnconsumedTopLevelCount { get; }
|
||||
internal int UnconsumedModernCount { get; }
|
||||
internal int UnconsumedCount => UnconsumedTopLevelCount+UnconsumedModernCount;
|
||||
internal PlayerKVDecodedSave(string playerId,long epoch,string version,IReadOnlyDictionary<string,string> raw,object[] modules,int unconsumedTop=0,int unconsumedModern=0)
|
||||
{
|
||||
if(modules==null || modules.Length!=31 || modules.Any(m=>m==null)) throw new ArgumentException("Complete decoded save required.");
|
||||
PlayerId=playerId; Epoch=epoch; ConfigurationVersion=version;
|
||||
Raw=new ReadOnlyDictionary<string,string>(raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
|
||||
this.modules=(object[])modules.Clone();
|
||||
UnconsumedTopLevelCount=unconsumedTop; UnconsumedModernCount=unconsumedModern;
|
||||
}
|
||||
internal object Module(int id) { if(id<1 || id>31) throw new ArgumentOutOfRangeException(nameof(id)); return modules[id-1]; }
|
||||
internal T Module<T>(int id) where T:class => Module(id) as T ?? throw new InvalidOperationException("Module state mismatch.");
|
||||
}
|
||||
internal sealed class PlayerKVSchemaResult
|
||||
{
|
||||
internal PlayerKVSchemaVerdict Verdict { get; }
|
||||
internal PlayerKVSchemaDiagnostics Diagnostics { get; }
|
||||
internal PlayerKVDecodedSave Data { get; }
|
||||
internal PlayerKVSchemaResult(PlayerKVSchemaVerdict verdict,PlayerKVSchemaDiagnostics diagnostics,PlayerKVDecodedSave data=null)
|
||||
{ Verdict=verdict; Diagnostics=diagnostics; Data=verdict==PlayerKVSchemaVerdict.Valid ? data : null; }
|
||||
}
|
||||
internal enum PlayerKVIdentityChannel { hwGoogle, hwFacebook, hwGuest, hwVkid, Apple, none }
|
||||
internal sealed class PlayerKVIdentitySnapshot
|
||||
{
|
||||
internal string UserId { get; }
|
||||
internal bool NewlyCreated { get; }
|
||||
internal PlayerKVIdentityChannel Channel { get; }
|
||||
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
|
||||
internal PlayerKVIdentitySnapshot(string userId,bool newlyCreated,PlayerKVIdentityChannel channel,IEnumerable<PlayerKVIdentityChannel> linked)
|
||||
{
|
||||
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel) || linked==null) throw new ArgumentException("Identity snapshot required.");
|
||||
var copy=linked.Distinct().ToList();
|
||||
if(copy.Any(c=>!Enum.IsDefined(typeof(PlayerKVIdentityChannel),c))) throw new ArgumentException("Unknown identity channel.");
|
||||
UserId=userId; NewlyCreated=newlyCreated; Channel=channel; Linked=copy.AsReadOnly();
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVLoginState
|
||||
{
|
||||
internal int MMTest { get; }
|
||||
internal int TapJoyBalance { get; }
|
||||
internal IReadOnlyDictionary<string,string> AccountUrls { get; }
|
||||
internal IReadOnlyDictionary<string,string> Normalizations { get; }
|
||||
internal IReadOnlyList<PlayerKVIdentityChannel> Linked { get; }
|
||||
internal PlayerKVIdentityChannel Channel { get; }
|
||||
internal PlayerKVLoginState(int mm,int balance,IDictionary<string,string> urls,IDictionary<string,string> normalizations,
|
||||
IEnumerable<PlayerKVIdentityChannel> linked,PlayerKVIdentityChannel channel)
|
||||
{
|
||||
MMTest=mm; TapJoyBalance=balance;
|
||||
AccountUrls=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(urls,StringComparer.Ordinal));
|
||||
Normalizations=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(normalizations,StringComparer.Ordinal));
|
||||
Linked=linked.Distinct().ToList().AsReadOnly(); Channel=channel;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPartnerBot
|
||||
{
|
||||
internal string Id { get; }
|
||||
internal int ScorePlayer { get; }
|
||||
internal int ScoreBot { get; }
|
||||
internal int Grade { get; }
|
||||
internal int TargetScore { get; }
|
||||
internal DateTime WakeStartTime { get; }
|
||||
internal int WakingSeconds { get; }
|
||||
internal PlayerKVPartnerBot(string id,int player,int bot,int grade,int target,DateTime wake,int seconds)
|
||||
{ Id=id; ScorePlayer=player; ScoreBot=bot; Grade=grade; TargetScore=target; WakeStartTime=wake; WakingSeconds=seconds; }
|
||||
}
|
||||
internal sealed class PlayerKVPartnerState
|
||||
{
|
||||
internal bool Present { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int CurrentPoints { get; }
|
||||
internal int CurrentMiningProgress { get; }
|
||||
internal int Token { get; }
|
||||
internal int InvitedCount { get; }
|
||||
internal int TicketCount { get; }
|
||||
internal IReadOnlyList<IReadOnlyList<int>> CompletedNodesList { get; }
|
||||
internal IReadOnlyList<(int myScore,int FriendScore)> BuilderPartnerScore { get; }
|
||||
internal float BonusPercent { get; }
|
||||
internal DateTime BonusExpireUtc { get; }
|
||||
internal int FreeSpinNum { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVPartnerBot> Bots { get; }
|
||||
internal bool IsRecvFinalReward { get; }
|
||||
internal IReadOnlyList<bool> UIFinishedPartners { get; }
|
||||
internal bool IsFinished { get; }
|
||||
internal int SpinState { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal bool IsEnableAutoMatch { get; }
|
||||
internal DateTime LastAutoMatchTime { get; }
|
||||
internal PlayerKVPartnerState(bool present,bool firstOpen,int eventId,DateTime start,DateTime end,
|
||||
int points,int progress,int token,int invited,int tickets,IEnumerable<IEnumerable<int>> nodes,
|
||||
IEnumerable<(int,int)> scores,float bonus,DateTime expires,int free,IDictionary<int,PlayerKVPartnerBot> bots,
|
||||
bool reward,IEnumerable<bool> ui,bool finished,int spin,float inflation,bool autoMatch,DateTime lastMatch)
|
||||
{
|
||||
Present=present; FirstOpen=firstOpen; EventId=eventId; StartTime=start; EndTime=end;
|
||||
CurrentPoints=points; CurrentMiningProgress=progress; Token=token; InvitedCount=invited; TicketCount=tickets;
|
||||
CompletedNodesList=nodes.Select(n=>(IReadOnlyList<int>)new List<int>(n).AsReadOnly()).ToList().AsReadOnly();
|
||||
BuilderPartnerScore=new List<(int,int)>(scores).AsReadOnly(); BonusPercent=bonus; BonusExpireUtc=expires;
|
||||
FreeSpinNum=free; Bots=new ReadOnlyDictionary<int,PlayerKVPartnerBot>(new Dictionary<int,PlayerKVPartnerBot>(bots));
|
||||
IsRecvFinalReward=reward; UIFinishedPartners=new List<bool>(ui).AsReadOnly(); IsFinished=finished;
|
||||
SpinState=spin; InflationRate=inflation; IsEnableAutoMatch=autoMatch; LastAutoMatchTime=lastMatch;
|
||||
}
|
||||
internal static PlayerKVPartnerState Missing() => new PlayerKVPartnerState(false,false,0,default,default,
|
||||
0,0,0,0,0,Array.Empty<int[]>(),Array.Empty<(int,int)>(),0,default,0,new Dictionary<int,PlayerKVPartnerBot>(),
|
||||
false,Array.Empty<bool>(),false,0,0,false,default);
|
||||
}
|
||||
internal enum PlayerKVSchemaError { Malformed, MissingCore, ConflictingRepresentations, UnsupportedSchema }
|
||||
|
||||
internal sealed class PlayerKVSchemaException : Exception
|
||||
{
|
||||
internal PlayerKVSchemaError Code { get; }
|
||||
internal PlayerKVSchemaException(PlayerKVSchemaError code = PlayerKVSchemaError.Malformed)
|
||||
: base("KV schema rejected.") { Code = code; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVSchemaDetail
|
||||
{
|
||||
internal int Module { get; }
|
||||
internal int Field { get; }
|
||||
internal PlayerKVSchemaError Code { get; }
|
||||
internal PlayerKVSchemaDetail(int module, int field, PlayerKVSchemaError code)
|
||||
{ Module = module; Field = field; Code = code; }
|
||||
}
|
||||
|
||||
// Values, save keys and exception messages are deliberately absent from diagnostic output.
|
||||
internal sealed class PlayerKVSchemaDiagnostics
|
||||
{
|
||||
readonly List<PlayerKVSchemaDetail> details = new List<PlayerKVSchemaDetail>();
|
||||
readonly SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>> counts =
|
||||
new SortedDictionary<int, Dictionary<PlayerKVSchemaError, int>>();
|
||||
internal int TotalErrors { get; private set; }
|
||||
internal int OmittedDetails => TotalErrors - details.Count;
|
||||
internal IReadOnlyList<PlayerKVSchemaDetail> Details => details.AsReadOnly();
|
||||
internal int Count(int module, PlayerKVSchemaError code) =>
|
||||
counts.TryGetValue(module, out var codes) && codes.TryGetValue(code, out var count) ? count : 0;
|
||||
internal void Add(int module, int field, PlayerKVSchemaError code)
|
||||
{
|
||||
if (module < 1 || module > 31 || field < 0) throw new ArgumentOutOfRangeException();
|
||||
TotalErrors++;
|
||||
if (!counts.TryGetValue(module, out var codes)) counts.Add(module, codes = new Dictionary<PlayerKVSchemaError, int>());
|
||||
codes[code] = Count(module, code) + 1;
|
||||
// Only retain 32 items, even when callers visit representations in different orders.
|
||||
var item = new PlayerKVSchemaDetail(module, field, code);
|
||||
int index = details.FindIndex(x => x.Module > module || (x.Module == module && x.Field > field));
|
||||
if (index < 0) index = details.Count;
|
||||
if (index < 32) details.Insert(index, item);
|
||||
if (details.Count > 32) details.RemoveAt(32);
|
||||
}
|
||||
internal T Capture<T>(int module, int field, Func<T> parse)
|
||||
{
|
||||
try { return parse(); }
|
||||
catch (PlayerKVSchemaException ex) { Add(module, field, ex.Code); return default; }
|
||||
}
|
||||
}
|
||||
|
||||
// Captured copies only: decoding never resolves Unity services or reads current culture/time.
|
||||
internal sealed class PlayerKVSchemaSnapshot
|
||||
{
|
||||
readonly HashSet<int> rods, maps;
|
||||
readonly IReadOnlyDictionary<int, int> packTypes;
|
||||
readonly IReadOnlyDictionary<int, PlayerKVEventConfig> events;
|
||||
readonly IReadOnlyDictionary<string, int> conditions;
|
||||
readonly HashSet<int> conditionValues;
|
||||
readonly IReadOnlyDictionary<int,PlayerKVFishConfig> fish;
|
||||
readonly IReadOnlyDictionary<int,PlayerKVFishCardConfig> cards;
|
||||
internal string Version { get; }
|
||||
internal CultureInfo Culture { get; }
|
||||
internal DateTime NowUtc { get; }
|
||||
internal DateTime NowGame { get; }
|
||||
internal int InitWheelTicket { get; }
|
||||
internal PlayerKVSchemaSnapshot(string version, IEnumerable<int> rods, IEnumerable<int> maps,
|
||||
IDictionary<string, int> conditions, CultureInfo culture, DateTime nowUtc, int initWheelTicket,
|
||||
IDictionary<int, int> packTypes = null, IDictionary<int, PlayerKVEventConfig> events = null, int globalTimeOffset = 0,
|
||||
IDictionary<int,PlayerKVFishConfig> fish = null, IDictionary<int,PlayerKVFishCardConfig> cards = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(version) || rods == null || maps == null || conditions == null || culture == null)
|
||||
throw new ArgumentException("Schema snapshot required.");
|
||||
Version = version; this.rods = new HashSet<int>(rods); this.maps = new HashSet<int>(maps);
|
||||
this.packTypes = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(packTypes ?? new Dictionary<int, int>()));
|
||||
this.events = new ReadOnlyDictionary<int, PlayerKVEventConfig>(new Dictionary<int, PlayerKVEventConfig>(events ?? new Dictionary<int, PlayerKVEventConfig>()));
|
||||
this.conditions = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>(conditions, StringComparer.Ordinal));
|
||||
conditionValues = new HashSet<int>(conditions.Values);
|
||||
Culture = CultureInfo.ReadOnly((CultureInfo)culture.Clone()); NowUtc = nowUtc; InitWheelTicket = initWheelTicket;
|
||||
NowGame = nowUtc.AddHours(-(double)globalTimeOffset);
|
||||
this.fish=new ReadOnlyDictionary<int,PlayerKVFishConfig>(new Dictionary<int,PlayerKVFishConfig>(fish ?? new Dictionary<int,PlayerKVFishConfig>()));
|
||||
this.cards=new ReadOnlyDictionary<int,PlayerKVFishCardConfig>(new Dictionary<int,PlayerKVFishCardConfig>(cards ?? new Dictionary<int,PlayerKVFishCardConfig>()));
|
||||
}
|
||||
internal PlayerKVFishConfig Fish(int id) => fish.TryGetValue(id,out var value) ? value : null;
|
||||
internal PlayerKVFishCardConfig Card(int id) => cards.TryGetValue(id,out var value) ? value : null;
|
||||
internal bool HasRod(int id) => rods.Contains(id);
|
||||
internal bool HasPack(int id) => packTypes.ContainsKey(id);
|
||||
internal PlayerKVEventConfig Event(int id) => events.TryGetValue(id,out var value) ? value : null;
|
||||
internal bool HasPackType(int id, int type) => packTypes.TryGetValue(id, out var actual) && actual == type;
|
||||
internal bool HasMap(int id) => maps.Contains(id);
|
||||
internal int Condition(string text)
|
||||
{
|
||||
if (conditions.TryGetValue(text, out var named)) return named;
|
||||
if (int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var numeric)
|
||||
&& conditionValues.Contains(numeric)) return numeric;
|
||||
throw new PlayerKVSchemaException();
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVCoreState
|
||||
{
|
||||
readonly object[] slots;
|
||||
internal PlayerKVCoreState(object[] slots) { this.slots = (object[])slots.Clone(); }
|
||||
internal int Integer(int slot) => (int)slots[slot];
|
||||
internal ulong Unsigned(int slot) => (ulong)slots[slot];
|
||||
internal DateTime EnergyTime => (DateTime)slots[7];
|
||||
internal bool Matches(int slot, object value) => Equals(slots[slot], value);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAchievementValue
|
||||
{
|
||||
internal int Id { get; }
|
||||
internal ulong Target { get; }
|
||||
internal int Day { get; }
|
||||
internal PlayerKVAchievementValue(int id, ulong target, int day) { Id = id; Target = target; Day = day; }
|
||||
internal bool Same(PlayerKVAchievementValue other) => Id == other.Id && Target == other.Target && Day == other.Day;
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAchievementState
|
||||
{
|
||||
internal IReadOnlyDictionary<int, PlayerKVAchievementValue> Values { get; }
|
||||
internal PlayerKVAchievementState(IDictionary<int, PlayerKVAchievementValue> values)
|
||||
{ Values = new ReadOnlyDictionary<int, PlayerKVAchievementValue>(new Dictionary<int, PlayerKVAchievementValue>(values)); }
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVPiggyBankState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int currentEventID { get; }
|
||||
internal int purchaseCount { get; }
|
||||
internal int VIPLvlWhenEventActivate { get; }
|
||||
internal int savingProgress { get; }
|
||||
internal int redirectID { get; }
|
||||
internal bool doNeedTrigger { get; }
|
||||
internal bool isTriggered { get; }
|
||||
internal float inflation { get; }
|
||||
internal PlayerKVPiggyBankState(bool present, int currentEventID, int purchaseCount, int VIPLvlWhenEventActivate, int savingProgress, int redirectID, bool doNeedTrigger, bool isTriggered, float inflation)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.currentEventID = currentEventID;
|
||||
this.purchaseCount = purchaseCount;
|
||||
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
|
||||
this.savingProgress = savingProgress;
|
||||
this.redirectID = redirectID;
|
||||
this.doNeedTrigger = doNeedTrigger;
|
||||
this.isTriggered = isTriggered;
|
||||
this.inflation = inflation;
|
||||
}
|
||||
internal static PlayerKVPiggyBankState Missing() => new PlayerKVPiggyBankState(false, 0, 0, 0, 0, 0, false, false, 0);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVBargainState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int currentEventID { get; }
|
||||
internal int redirectID { get; }
|
||||
internal int VIPLvlWhenEventActivate { get; }
|
||||
internal int purchaseCount { get; }
|
||||
internal int savingProgress { get; }
|
||||
internal int discountLvl { get; }
|
||||
internal int visualProgress { get; }
|
||||
internal int visualDiscountLvl { get; }
|
||||
internal int rodID { get; }
|
||||
internal int lastPackIDBought { get; }
|
||||
internal int packID { get; }
|
||||
internal bool doNeedTrigger { get; }
|
||||
internal bool isTriggered { get; }
|
||||
internal float inflation { get; }
|
||||
internal IReadOnlyList<int> selections { get; }
|
||||
internal PlayerKVBargainState(bool present, int currentEventID, int redirectID, int VIPLvlWhenEventActivate, int purchaseCount, int savingProgress, int discountLvl, int visualProgress, int visualDiscountLvl, int rodID, int lastPackIDBought, int packID, bool doNeedTrigger, bool isTriggered, float inflation, IEnumerable<int> selections)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.currentEventID = currentEventID;
|
||||
this.redirectID = redirectID;
|
||||
this.VIPLvlWhenEventActivate = VIPLvlWhenEventActivate;
|
||||
this.purchaseCount = purchaseCount;
|
||||
this.savingProgress = savingProgress;
|
||||
this.discountLvl = discountLvl;
|
||||
this.visualProgress = visualProgress;
|
||||
this.visualDiscountLvl = visualDiscountLvl;
|
||||
this.rodID = rodID;
|
||||
this.lastPackIDBought = lastPackIDBought;
|
||||
this.packID = packID;
|
||||
this.doNeedTrigger = doNeedTrigger;
|
||||
this.isTriggered = isTriggered;
|
||||
this.inflation = inflation;
|
||||
this.selections = selections == null ? null : new List<int>(selections).AsReadOnly();
|
||||
}
|
||||
internal static PlayerKVBargainState Missing() => new PlayerKVBargainState(false, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, false, false, 0, Array.Empty<int>());
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVShootingRangeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventId { get; }
|
||||
internal int CycleId { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal int AmmoCount { get; }
|
||||
internal int CurrentStageIdx { get; }
|
||||
internal int ChainProgress { get; }
|
||||
internal long RewardFlag { get; }
|
||||
internal long TargetFlag { get; }
|
||||
internal bool HasBeenOpened { get; }
|
||||
internal float Inflation { get; }
|
||||
internal PlayerKVShootingRangeState(bool present, int EventId, int CycleId, int RedirectId, int AmmoCount, int CurrentStageIdx, int ChainProgress, long RewardFlag, long TargetFlag, bool HasBeenOpened, float Inflation)
|
||||
{
|
||||
IsPresent = present;
|
||||
this.EventId = EventId;
|
||||
this.CycleId = CycleId;
|
||||
this.RedirectId = RedirectId;
|
||||
this.AmmoCount = AmmoCount;
|
||||
this.CurrentStageIdx = CurrentStageIdx;
|
||||
this.ChainProgress = ChainProgress;
|
||||
this.RewardFlag = RewardFlag;
|
||||
this.TargetFlag = TargetFlag;
|
||||
this.HasBeenOpened = HasBeenOpened;
|
||||
this.Inflation = Inflation;
|
||||
}
|
||||
internal static PlayerKVShootingRangeState Missing() => new PlayerKVShootingRangeState(false, 0, 0, 0, 0, 0, 0, 0, 0, false, 0);
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVBuffValue
|
||||
{
|
||||
internal int SortID { get; }
|
||||
internal int sourceID { get; }
|
||||
internal int buffID { get; }
|
||||
internal bool isEnd { get; }
|
||||
internal DateTime buffEndTime { get; }
|
||||
internal PlayerKVBuffValue(int SortID,int sourceID,int buffID,bool isEnd,DateTime buffEndTime)
|
||||
{ this.SortID=SortID; this.sourceID=sourceID; this.buffID=buffID; this.isEnd=isEnd; this.buffEndTime=buffEndTime; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVTriggerValue
|
||||
{
|
||||
internal int ID { get; }
|
||||
internal int index { get; }
|
||||
internal int lastID { get; }
|
||||
internal int vipLevel { get; }
|
||||
internal int mapID { get; }
|
||||
internal string time { get; }
|
||||
internal PlayerKVTriggerValue(int ID,int index,int lastID,int vipLevel,int mapID,string time)
|
||||
{ this.ID=ID; this.index=index; this.lastID=lastID; this.vipLevel=vipLevel; this.mapID=mapID; this.time=time; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVGiftVipValue
|
||||
{
|
||||
internal int oldVipLevel { get; }
|
||||
internal int curVipLevel { get; }
|
||||
internal int timer { get; }
|
||||
internal PlayerKVGiftVipValue(int oldVipLevel,int curVipLevel,int timer)
|
||||
{ this.oldVipLevel=oldVipLevel; this.curVipLevel=curVipLevel; this.timer=timer; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVFixedTimeValue
|
||||
{
|
||||
internal int id { get; }
|
||||
internal string endTime { get; }
|
||||
internal PlayerKVFixedTimeValue(int id,string endTime)
|
||||
{ this.id=id; this.endTime=endTime; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVBuffState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Map { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVBuffValue> Global { get; }
|
||||
internal IReadOnlyList<PlayerKVBuffValue> Target { get; }
|
||||
internal PlayerKVBuffValue Daily { get; }
|
||||
internal PlayerKVBuffState(IDictionary<int,PlayerKVBuffValue> map,IDictionary<int,PlayerKVBuffValue> global,
|
||||
IEnumerable<PlayerKVBuffValue> target,PlayerKVBuffValue daily)
|
||||
{
|
||||
Map=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(map));
|
||||
Global=new ReadOnlyDictionary<int,PlayerKVBuffValue>(new Dictionary<int,PlayerKVBuffValue>(global));
|
||||
Target=new List<PlayerKVBuffValue>(target).AsReadOnly(); Daily=daily;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVTriggerState
|
||||
{
|
||||
internal IReadOnlyList<PlayerKVTriggerValue> Packs { get; }
|
||||
internal IReadOnlyDictionary<string,PlayerKVGiftVipValue> Gifts { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
|
||||
internal PlayerKVTriggerState(IEnumerable<PlayerKVTriggerValue> packs,IDictionary<string,PlayerKVGiftVipValue> gifts,
|
||||
IDictionary<int,PlayerKVFixedTimeValue> fixedValues)
|
||||
{
|
||||
Packs=new List<PlayerKVTriggerValue>(packs).AsReadOnly();
|
||||
Gifts=new ReadOnlyDictionary<string,PlayerKVGiftVipValue>(new Dictionary<string,PlayerKVGiftVipValue>(gifts,StringComparer.Ordinal));
|
||||
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
internal sealed class PlayerKVPlayerState
|
||||
{
|
||||
internal PlayerKVCoreState Core { get; }
|
||||
internal IReadOnlyDictionary<string,string> Modern { get; }
|
||||
internal IReadOnlyDictionary<int,int> VipScores { get; }
|
||||
internal IReadOnlyList<int> NewRods { get; }
|
||||
internal int Season { get; }
|
||||
internal bool Appraise { get; }
|
||||
internal string Building { get; }
|
||||
internal PlayerKVPlayerState(PlayerKVCoreState core,IDictionary<string,string> modern,IDictionary<int,int> scores,
|
||||
IEnumerable<int> rods,int season,bool appraise,string building)
|
||||
{
|
||||
Core=core; Modern=new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(modern,StringComparer.Ordinal));
|
||||
VipScores=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(scores)); NewRods=new List<int>(rods).AsReadOnly();
|
||||
Season=season; Appraise=appraise; Building=building;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPotionItem
|
||||
{
|
||||
internal string Name { get; }
|
||||
internal int Index { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal int DataType { get; }
|
||||
internal IReadOnlyList<int> Colors { get; }
|
||||
internal IReadOnlyList<int> Shelves { get; }
|
||||
internal IReadOnlyList<int> Grids { get; }
|
||||
internal IReadOnlyList<int> FromDisplay { get; }
|
||||
internal PlayerKVPotionItem(string name,int index,bool over,int type,IEnumerable<int> colors,
|
||||
IEnumerable<int> shelves,IEnumerable<int> grids,IEnumerable<int> fromDisplay)
|
||||
{
|
||||
Name=name; Index=index; IsOver=over; DataType=type;
|
||||
Colors=new List<int>(colors).AsReadOnly(); Shelves=new List<int>(shelves).AsReadOnly();
|
||||
Grids=new List<int>(grids).AsReadOnly(); FromDisplay=new List<int>(fromDisplay).AsReadOnly();
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVPotionState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int PotionID { get; }
|
||||
internal int StageID { get; }
|
||||
internal int Stage1TotalLevel { get; }
|
||||
internal int Stage2TotalLevel { get; }
|
||||
internal int LevelID { get; }
|
||||
internal int LevelIndex { get; }
|
||||
internal int CorrectNum { get; }
|
||||
internal int SubmitNum { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int Rule { get; }
|
||||
internal int PreEventID { get; }
|
||||
internal int PrePotionID { get; }
|
||||
internal int PreStageID { get; }
|
||||
internal int PreLevelID { get; }
|
||||
internal int Round { get; }
|
||||
internal int FirstPlayedEventId { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal string Name { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVPotionItem LevelTargetData { get; }
|
||||
internal PlayerKVPotionItem LevelDisplayData { get; }
|
||||
internal IReadOnlyList<PlayerKVPotionItem> LevelProcessedList { get; }
|
||||
internal PlayerKVPotionState(bool present,int EventID,int PotionID,int StageID,int Stage1TotalLevel,int Stage2TotalLevel,int LevelID,int LevelIndex,int CorrectNum,int SubmitNum,int Token,int ConsumedToken,int Rule,int PreEventID,int PrePotionID,int PreStageID,int PreLevelID,int Round,int FirstPlayedEventId,bool IsOver,string Name,float InflationRate,PlayerKVPotionItem LevelTargetData,PlayerKVPotionItem LevelDisplayData,List<PlayerKVPotionItem> LevelProcessedList)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.PotionID=PotionID;
|
||||
this.StageID=StageID;
|
||||
this.Stage1TotalLevel=Stage1TotalLevel;
|
||||
this.Stage2TotalLevel=Stage2TotalLevel;
|
||||
this.LevelID=LevelID;
|
||||
this.LevelIndex=LevelIndex;
|
||||
this.CorrectNum=CorrectNum;
|
||||
this.SubmitNum=SubmitNum;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.Rule=Rule;
|
||||
this.PreEventID=PreEventID;
|
||||
this.PrePotionID=PrePotionID;
|
||||
this.PreStageID=PreStageID;
|
||||
this.PreLevelID=PreLevelID;
|
||||
this.Round=Round;
|
||||
this.FirstPlayedEventId=FirstPlayedEventId;
|
||||
this.IsOver=IsOver;
|
||||
this.Name=Name;
|
||||
this.InflationRate=InflationRate;
|
||||
this.LevelTargetData=LevelTargetData;
|
||||
this.LevelDisplayData=LevelDisplayData;
|
||||
this.LevelProcessedList=new List<PlayerKVPotionItem>(LevelProcessedList).AsReadOnly();
|
||||
}
|
||||
internal static PlayerKVPotionState Missing() => new PlayerKVPotionState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<PlayerKVPotionItem>());
|
||||
}
|
||||
internal sealed class PlayerKVPassRecord
|
||||
{
|
||||
internal int ID { get; }
|
||||
internal int Level { get; }
|
||||
internal int Exp { get; }
|
||||
internal int BuyVipToastIndex { get; }
|
||||
internal int IsUnlockVip { get; }
|
||||
internal IReadOnlyList<int> Normal { get; }
|
||||
internal IReadOnlyList<int> Vip { get; }
|
||||
internal IReadOnlyList<int> LastDrops { get; }
|
||||
internal float Inflation { get; }
|
||||
internal PlayerKVPassRecord(int id,int level,int exp,int toast,int unlock,IEnumerable<int> normal,
|
||||
IEnumerable<int> vip,IEnumerable<int> lastDrops,float inflation)
|
||||
{
|
||||
ID=id; Level=level; Exp=exp; BuyVipToastIndex=toast; IsUnlockVip=unlock;
|
||||
Normal=new List<int>(normal).AsReadOnly(); Vip=new List<int>(vip).AsReadOnly();
|
||||
LastDrops=new List<int>(lastDrops).AsReadOnly(); Inflation=inflation;
|
||||
}
|
||||
internal bool CommonEquals(PlayerKVPassRecord other) => ID==other.ID && Level==other.Level && Exp==other.Exp &&
|
||||
BuyVipToastIndex==other.BuyVipToastIndex && IsUnlockVip==other.IsUnlockVip && Normal.SequenceEqual(other.Normal) && Vip.SequenceEqual(other.Vip);
|
||||
internal static PlayerKVPassRecord Empty() => new PlayerKVPassRecord(0,1,0,0,0,Array.Empty<int>(),Array.Empty<int>(),Array.Empty<int>(),0);
|
||||
}
|
||||
internal sealed class PlayerKVPassTask
|
||||
{
|
||||
internal IReadOnlyDictionary<int,int> Daily { get; }
|
||||
internal IReadOnlyDictionary<int,int> Weekly { get; }
|
||||
internal IReadOnlyList<int> LoginDays { get; }
|
||||
internal DateTime DailyEnd { get; }
|
||||
internal DateTime WeeklyEnd { get; }
|
||||
internal PlayerKVPassTask(IDictionary<int,int> daily,IDictionary<int,int> weekly,IEnumerable<int> days,DateTime dailyEnd,DateTime weeklyEnd)
|
||||
{
|
||||
Daily=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(daily)); Weekly=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(weekly));
|
||||
LoginDays=new List<int>(days).AsReadOnly(); DailyEnd=dailyEnd; WeeklyEnd=weeklyEnd;
|
||||
}
|
||||
internal bool Same(PlayerKVPassTask other) => Daily.Count==other.Daily.Count && Weekly.Count==other.Weekly.Count &&
|
||||
Daily.All(p=>other.Daily.TryGetValue(p.Key,out var value) && value==p.Value) &&
|
||||
Weekly.All(p=>other.Weekly.TryGetValue(p.Key,out var value) && value==p.Value) &&
|
||||
LoginDays.SequenceEqual(other.LoginDays) && DailyEnd==other.DailyEnd && WeeklyEnd==other.WeeklyEnd;
|
||||
internal static PlayerKVPassTask Empty() => new PlayerKVPassTask(new Dictionary<int,int>(),new Dictionary<int,int>(),Array.Empty<int>(),default,default);
|
||||
}
|
||||
internal sealed class PlayerKVBattlePassState
|
||||
{
|
||||
internal PlayerKVPassRecord Record { get; }
|
||||
internal PlayerKVPassTask Task { get; }
|
||||
internal PlayerKVBattlePassState(PlayerKVPassRecord record,PlayerKVPassTask task) { Record=record; Task=task; }
|
||||
}
|
||||
internal sealed class PlayerKVCollectValue
|
||||
{
|
||||
internal int ItemId { get; }
|
||||
internal int Count { get; }
|
||||
internal int MaxCount { get; }
|
||||
internal int ItemTrueId { get; }
|
||||
internal PlayerKVCollectValue(int itemId,int count,int maxCount,int trueId)
|
||||
{ ItemId=itemId; Count=count; MaxCount=maxCount; ItemTrueId=trueId; }
|
||||
}
|
||||
internal sealed class PlayerKVCubeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int TicketCount { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal bool IsFinished { get; }
|
||||
internal int EventId { get; }
|
||||
internal bool IsInGuide { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int CurrentStagetId { get; }
|
||||
internal IReadOnlyList<PlayerKVCollectValue> CollectItems { get; }
|
||||
internal IReadOnlyList<int> BoardData { get; }
|
||||
internal int Width { get; }
|
||||
internal int Height { get; }
|
||||
internal IReadOnlyList<int> CollectRewardsId { get; }
|
||||
internal bool IsFinalRewardGet { get; }
|
||||
internal IReadOnlyList<IReadOnlyList<int>> PieceDatas { get; }
|
||||
internal int Cycle { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVCubeState(bool present,int ticketCount,bool firstOpen,bool finished,int eventId,bool guide,
|
||||
DateTime start,DateTime end,int stage,IEnumerable<PlayerKVCollectValue> items,IEnumerable<int> board,
|
||||
int width,int height,IEnumerable<int> rewards,bool final,IEnumerable<IEnumerable<int>> pieces,int cycle,float inflation)
|
||||
{
|
||||
IsPresent=present; TicketCount=ticketCount; FirstOpen=firstOpen; IsFinished=finished; EventId=eventId; IsInGuide=guide;
|
||||
StartTime=start; EndTime=end; CurrentStagetId=stage; CollectItems=new List<PlayerKVCollectValue>(items).AsReadOnly();
|
||||
BoardData=new List<int>(board).AsReadOnly(); Width=width; Height=height; CollectRewardsId=new List<int>(rewards).AsReadOnly();
|
||||
IsFinalRewardGet=final; PieceDatas=pieces.Select(p=>(IReadOnlyList<int>)new List<int>(p).AsReadOnly()).ToList().AsReadOnly();
|
||||
Cycle=cycle; InflationRate=inflation;
|
||||
}
|
||||
internal static PlayerKVCubeState Missing() => new PlayerKVCubeState(false,0,false,false,0,false,default,default,0,
|
||||
Array.Empty<PlayerKVCollectValue>(),Array.Empty<int>(),0,0,Array.Empty<int>(),false,Array.Empty<IEnumerable<int>>(),0,0);
|
||||
}
|
||||
internal sealed class PlayerKVPinataState
|
||||
{
|
||||
internal int EventID { get; }
|
||||
internal int PinataMainID { get; }
|
||||
internal int PurchasedCount { get; }
|
||||
internal PlayerKVPinataState(int eventId,int mainId,int count) { EventID=eventId; PinataMainID=mainId; PurchasedCount=count; }
|
||||
}
|
||||
internal sealed class PlayerKVEmptyModuleState
|
||||
{
|
||||
internal static readonly PlayerKVEmptyModuleState Instance=new PlayerKVEmptyModuleState();
|
||||
private PlayerKVEmptyModuleState() { }
|
||||
}
|
||||
internal sealed class PlayerKVStampValue
|
||||
{
|
||||
internal int EventScratchCardID { get; }
|
||||
internal int Count { get; }
|
||||
internal PlayerKVStampValue(int id,int count) { EventScratchCardID=id; Count=count; }
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVChallengeState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
/// <summary>Internal in-memory representation of a present JSON null; not serialized to the server.</summary>
|
||||
internal bool IsExplicitNull { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal int RankTierLevel { get; }
|
||||
internal int Finished { get; }
|
||||
internal int RedirectID { get; }
|
||||
internal int Score { get; }
|
||||
internal int ProcessedScore { get; }
|
||||
internal int step { get; }
|
||||
internal int uiStep { get; }
|
||||
internal int index { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal IReadOnlyList<int> eliminateNumber { get; }
|
||||
internal IReadOnlyList<int> robotAccountList { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVChallengeState(bool present,bool FirstOpen,int EventId,int RankTierLevel,int Finished,int RedirectID,int Score,int ProcessedScore,int step,int uiStep,int index,DateTime StartTime,DateTime EndTime,List<int> eliminateNumber,List<int> robotAccountList,float InflationRate,bool explicitNull=false)
|
||||
{
|
||||
if (explicitNull && !present) throw new ArgumentException("An explicit null must have a present key.", nameof(explicitNull));
|
||||
IsPresent=present;
|
||||
IsExplicitNull=explicitNull;
|
||||
this.FirstOpen=FirstOpen;
|
||||
this.EventId=EventId;
|
||||
this.RankTierLevel=RankTierLevel;
|
||||
this.Finished=Finished;
|
||||
this.RedirectID=RedirectID;
|
||||
this.Score=Score;
|
||||
this.ProcessedScore=ProcessedScore;
|
||||
this.step=step;
|
||||
this.uiStep=uiStep;
|
||||
this.index=index;
|
||||
this.StartTime=StartTime;
|
||||
this.EndTime=EndTime;
|
||||
this.eliminateNumber=new List<int>(eliminateNumber).AsReadOnly();
|
||||
this.robotAccountList=new List<int>(robotAccountList).AsReadOnly();
|
||||
this.InflationRate=InflationRate;
|
||||
}
|
||||
internal static PlayerKVChallengeState Missing() => new PlayerKVChallengeState(false,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default);
|
||||
/// <summary>Creates an internal empty-state marker, not a server serialization payload.</summary>
|
||||
internal static PlayerKVChallengeState ExplicitNull() => new PlayerKVChallengeState(true,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new List<int>(),default,true);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVPinballState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int PinballMainID { get; }
|
||||
internal int SpinPoint { get; }
|
||||
internal int Scores { get; }
|
||||
internal int Multiple { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int RewardIndex { get; }
|
||||
internal float Force { get; }
|
||||
internal float LuckyValue { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal PlayerKVPinballState(bool present,int EventID,int PinballMainID,int SpinPoint,int Scores,int Multiple,int Token,int ConsumedToken,int RewardIndex,float Force,float LuckyValue,float InflationRate)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.PinballMainID=PinballMainID;
|
||||
this.SpinPoint=SpinPoint;
|
||||
this.Scores=Scores;
|
||||
this.Multiple=Multiple;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.RewardIndex=RewardIndex;
|
||||
this.Force=Force;
|
||||
this.LuckyValue=LuckyValue;
|
||||
this.InflationRate=InflationRate;
|
||||
}
|
||||
internal static PlayerKVPinballState Missing() => new PlayerKVPinballState(false,default,default,default,default,default,default,default,default,default,default,default);
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVScratchState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventID { get; }
|
||||
internal int ScratchMainID { get; }
|
||||
internal int ChainPackID { get; }
|
||||
internal int Token { get; }
|
||||
internal int ConsumedToken { get; }
|
||||
internal int RewardTaskConsumedToken { get; }
|
||||
internal int EventScratchRewardTaskID { get; }
|
||||
internal int EventScratchCardID { get; }
|
||||
internal int TotalConsumedToken { get; }
|
||||
internal int ChainPackProgress { get; }
|
||||
internal int magIndex { get; }
|
||||
internal bool IsOver { get; }
|
||||
internal float LuckyValue { get; }
|
||||
internal float InflationRate { get; }
|
||||
internal IReadOnlyList<int> StampRewardList { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVStampValue> StampRewardDict { get; }
|
||||
internal PlayerKVScratchState(bool present,int EventID,int ScratchMainID,int ChainPackID,int Token,int ConsumedToken,int RewardTaskConsumedToken,int EventScratchRewardTaskID,int EventScratchCardID,int TotalConsumedToken,int ChainPackProgress,int magIndex,bool IsOver,float LuckyValue,float InflationRate,List<int> StampRewardList,Dictionary<int,PlayerKVStampValue> StampRewardDict)
|
||||
{
|
||||
IsPresent=present;
|
||||
this.EventID=EventID;
|
||||
this.ScratchMainID=ScratchMainID;
|
||||
this.ChainPackID=ChainPackID;
|
||||
this.Token=Token;
|
||||
this.ConsumedToken=ConsumedToken;
|
||||
this.RewardTaskConsumedToken=RewardTaskConsumedToken;
|
||||
this.EventScratchRewardTaskID=EventScratchRewardTaskID;
|
||||
this.EventScratchCardID=EventScratchCardID;
|
||||
this.TotalConsumedToken=TotalConsumedToken;
|
||||
this.ChainPackProgress=ChainPackProgress;
|
||||
this.magIndex=magIndex;
|
||||
this.IsOver=IsOver;
|
||||
this.LuckyValue=LuckyValue;
|
||||
this.InflationRate=InflationRate;
|
||||
this.StampRewardList=new List<int>(StampRewardList).AsReadOnly();
|
||||
this.StampRewardDict=new ReadOnlyDictionary<int,PlayerKVStampValue>(new Dictionary<int,PlayerKVStampValue>(StampRewardDict));
|
||||
}
|
||||
internal static PlayerKVScratchState Missing() => new PlayerKVScratchState(false,default,default,default,default,default,default,default,default,default,default,default,default,default,default,new List<int>(),new Dictionary<int,PlayerKVStampValue>());
|
||||
}
|
||||
internal sealed class PlayerKVOfferItem
|
||||
{
|
||||
internal int Id { get; }
|
||||
internal int Item { get; }
|
||||
internal int Number { get; }
|
||||
internal int Status { get; }
|
||||
internal PlayerKVOfferItem(int id,int item,int number,int status) { Id=id; Item=item; Number=number; Status=status; }
|
||||
}
|
||||
internal sealed class PlayerKVOfferState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal bool FirstOpen { get; }
|
||||
internal int EventId { get; }
|
||||
internal DateTime StartTime { get; }
|
||||
internal DateTime EndTime { get; }
|
||||
internal int ItemId { get; }
|
||||
internal int LotteryNum { get; }
|
||||
internal IReadOnlyList<PlayerKVOfferItem> Items { get; }
|
||||
internal IReadOnlyList<int> RewardList { get; }
|
||||
internal bool Finished { get; }
|
||||
internal int ChestReward { get; }
|
||||
internal PlayerKVOfferState(bool present,bool firstOpen,int eventId,DateTime startTime,DateTime endTime,
|
||||
int itemId,int lotteryNum,IEnumerable<PlayerKVOfferItem> items,IEnumerable<int> rewardList,bool finished,int chestReward)
|
||||
{
|
||||
IsPresent=present; FirstOpen=firstOpen; EventId=eventId; StartTime=startTime; EndTime=endTime;
|
||||
ItemId=itemId; LotteryNum=lotteryNum; Items=new List<PlayerKVOfferItem>(items).AsReadOnly();
|
||||
RewardList=new List<int>(rewardList).AsReadOnly(); Finished=finished; ChestReward=chestReward;
|
||||
}
|
||||
internal static PlayerKVOfferState Missing() => new PlayerKVOfferState(false,false,0,default,default,0,0,
|
||||
Array.Empty<PlayerKVOfferItem>(),Array.Empty<int>(),false,0);
|
||||
}
|
||||
internal sealed class PlayerKVEventConfig
|
||||
{
|
||||
internal int RedirectId { get; }
|
||||
internal string EndTime { get; }
|
||||
internal PlayerKVEventConfig(int redirectId,string endTime) { RedirectId=redirectId; EndTime=endTime; }
|
||||
}
|
||||
internal sealed class PlayerKVEventPackState
|
||||
{
|
||||
internal int EventId { get; }
|
||||
internal int PurchaseCount { get; }
|
||||
internal int VipLevel { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal PlayerKVEventPackState(int eventId,int purchaseCount,int vipLevel,int redirectId)
|
||||
{ EventId=eventId; PurchaseCount=purchaseCount; VipLevel=vipLevel; RedirectId=redirectId; }
|
||||
}
|
||||
internal sealed class PlayerKVCampState
|
||||
{
|
||||
internal bool IsFix { get; }
|
||||
internal string Skyscraper { get; }
|
||||
internal IReadOnlyDictionary<string,int> Values { get; }
|
||||
internal PlayerKVCampState(bool isFix,string skyscraper,IDictionary<string,int> values)
|
||||
{ IsFix=isFix; Skyscraper=skyscraper; Values=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(values,StringComparer.Ordinal)); }
|
||||
}
|
||||
internal sealed class PlayerKVClubState
|
||||
{
|
||||
internal DateTime? SignTime { get; }
|
||||
internal DateTime DonateTime { get; }
|
||||
internal int DonateCount { get; }
|
||||
internal DateTime RequestEnd { get; }
|
||||
internal bool OpenRed { get; }
|
||||
internal PlayerKVClubState(DateTime? signTime, DateTime donateTime, int donateCount, DateTime requestEnd, bool openRed)
|
||||
{ SignTime=signTime; DonateTime=donateTime; DonateCount=donateCount; RequestEnd=requestEnd; OpenRed=openRed; }
|
||||
}
|
||||
internal sealed class PlayerKVProgressPackState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int EventId { get; }
|
||||
internal int RedirectId { get; }
|
||||
internal int TokenProgress { get; }
|
||||
internal int ChainProgress { get; }
|
||||
internal PlayerKVProgressPackState(bool present, int eventId, int redirectId, int tokenProgress, int chainProgress)
|
||||
{ IsPresent=present; EventId=eventId; RedirectId=redirectId; TokenProgress=tokenProgress; ChainProgress=chainProgress; }
|
||||
}
|
||||
internal sealed class PlayerKVIntMapState
|
||||
{
|
||||
internal IReadOnlyDictionary<int, int> Values { get; }
|
||||
internal PlayerKVIntMapState(IDictionary<int, int> values)
|
||||
{ Values = new ReadOnlyDictionary<int, int>(new Dictionary<int, int>(values)); }
|
||||
}
|
||||
internal sealed class PlayerKVGuideValue
|
||||
{
|
||||
internal int Index { get; }
|
||||
internal bool Finished { get; }
|
||||
internal string Group { get; }
|
||||
internal PlayerKVGuideValue(int index, bool finished, string group)
|
||||
{ Index = index; Finished = finished; Group = group; }
|
||||
internal bool Same(PlayerKVGuideValue other) => Index == other.Index && Finished == other.Finished && Group == other.Group;
|
||||
}
|
||||
internal sealed class PlayerKVGuideState
|
||||
{
|
||||
internal IReadOnlyDictionary<string, PlayerKVGuideValue> Values { get; }
|
||||
internal PlayerKVGuideState(IDictionary<string, PlayerKVGuideValue> values)
|
||||
{ Values = new ReadOnlyDictionary<string, PlayerKVGuideValue>(new Dictionary<string, PlayerKVGuideValue>(values, StringComparer.Ordinal)); }
|
||||
}
|
||||
internal sealed class PlayerKVMonthlySignState
|
||||
{
|
||||
internal bool IsPresent { get; }
|
||||
internal int Index { get; }
|
||||
internal int DailyId { get; }
|
||||
internal int DailyIndex { get; }
|
||||
internal int LastId { get; }
|
||||
internal string Time { get; }
|
||||
internal PlayerKVMonthlySignState(bool present, int index, int dailyId, int dailyIndex, int lastId, string time)
|
||||
{ IsPresent = present; Index = index; DailyId = dailyId; DailyIndex = dailyIndex; LastId = lastId; Time = time; }
|
||||
}
|
||||
internal sealed class PlayerKVDailyValue
|
||||
{
|
||||
internal int Id { get; } internal int Index { get; } internal int LastId { get; } internal string Time { get; }
|
||||
internal PlayerKVDailyValue(int id,int index,int lastId,string time) { Id=id; Index=index; LastId=lastId; Time=time; }
|
||||
}
|
||||
internal sealed class PlayerKVAdvertValue
|
||||
{
|
||||
internal int Id { get; } internal int Index { get; } internal int Day { get; }
|
||||
internal string Time { get; } internal bool Open { get; }
|
||||
internal PlayerKVAdvertValue(int id,int index,int day,string time,bool open) { Id=id; Index=index; Day=day; Time=time; Open=open; }
|
||||
}
|
||||
internal sealed class PlayerKVShopItemValue
|
||||
{
|
||||
internal int Id { get; } internal float Discount { get; } internal int Bought { get; }
|
||||
internal PlayerKVShopItemValue(int id,float discount,int bought) { Id=id; Discount=discount; Bought=bought; }
|
||||
}
|
||||
internal sealed class PlayerKVCouponState
|
||||
{
|
||||
internal DateTime ActivateTime { get; } internal int EventId { get; } internal int RedirectId { get; } internal bool Bought { get; }
|
||||
internal PlayerKVCouponState(DateTime time,int eventId,int redirectId,bool bought) { ActivateTime=time; EventId=eventId; RedirectId=redirectId; Bought=bought; }
|
||||
}
|
||||
internal sealed class PlayerKVShopState
|
||||
{
|
||||
internal IReadOnlyDictionary<string,int> Daily { get; }
|
||||
internal IReadOnlyDictionary<int,int> All { get; } internal IReadOnlyDictionary<int,int> Gift { get; }
|
||||
internal IReadOnlyDictionary<int,int> Advert { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVShopItemValue> Items { get; }
|
||||
internal IReadOnlyList<float> Buy { get; }
|
||||
internal int Voucher { get; } internal int RefreshCount { get; } internal DateTime RefreshTime { get; }
|
||||
internal PlayerKVDailyValue Chain { get; } internal PlayerKVDailyValue Turntable { get; }
|
||||
internal PlayerKVAdvertValue Vip { get; } internal PlayerKVCouponState Coupon { get; }
|
||||
internal PlayerKVShopState(IDictionary<string,int> daily,IDictionary<int,int> all,IDictionary<int,int> gift,
|
||||
IDictionary<int,int> advert,IDictionary<int,PlayerKVShopItemValue> items,IEnumerable<float> buy,int voucher,int refreshCount,
|
||||
DateTime refreshTime,PlayerKVDailyValue chain,PlayerKVDailyValue turntable,PlayerKVAdvertValue vip,PlayerKVCouponState coupon)
|
||||
{
|
||||
Daily=new ReadOnlyDictionary<string,int>(new Dictionary<string,int>(daily,StringComparer.Ordinal));
|
||||
All=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(all)); Gift=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(gift));
|
||||
Advert=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(advert));
|
||||
Items=new ReadOnlyDictionary<int,PlayerKVShopItemValue>(new Dictionary<int,PlayerKVShopItemValue>(items));
|
||||
Buy=new List<float>(buy).AsReadOnly(); Voucher=voucher; RefreshCount=refreshCount; RefreshTime=refreshTime;
|
||||
Chain=chain; Turntable=turntable; Vip=vip; Coupon=coupon;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVLimitedValue
|
||||
{
|
||||
internal int Id { get; } internal double Duration { get; } internal string StartTime { get; }
|
||||
internal PlayerKVLimitedValue(int id,double duration,string time) { Id=id; Duration=duration; StartTime=time; }
|
||||
}
|
||||
internal sealed class PlayerKVEventBaseState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,PlayerKVLimitedValue> Limited { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFixedTimeValue> Fixed { get; }
|
||||
internal IReadOnlyDictionary<int,int> Target { get; } internal IReadOnlyDictionary<int,int> Rank { get; }
|
||||
internal IReadOnlyDictionary<int,int> Wheel { get; }
|
||||
internal int Coupon { get; } internal int Operate { get; } internal int AllOperate { get; }
|
||||
internal int Free { get; } internal int PvpToken { get; }
|
||||
internal PlayerKVEventBaseState(IDictionary<int,PlayerKVLimitedValue> limited,IDictionary<int,PlayerKVFixedTimeValue> fixedValues,
|
||||
IDictionary<int,int> target,IDictionary<int,int> rank,IDictionary<int,int> wheel,int coupon,int operate,int allOperate,int free,int pvpToken)
|
||||
{
|
||||
Limited=new ReadOnlyDictionary<int,PlayerKVLimitedValue>(new Dictionary<int,PlayerKVLimitedValue>(limited));
|
||||
Fixed=new ReadOnlyDictionary<int,PlayerKVFixedTimeValue>(new Dictionary<int,PlayerKVFixedTimeValue>(fixedValues));
|
||||
Target=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(target)); Rank=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rank));
|
||||
Wheel=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(wheel)); Coupon=coupon; Operate=operate; AllOperate=allOperate; Free=free; PvpToken=pvpToken;
|
||||
}
|
||||
}
|
||||
internal sealed class PlayerKVFishConfig
|
||||
{
|
||||
internal int Id { get; } internal int Area { get; } internal int Quality { get; } internal int Card { get; }
|
||||
internal float Weight { get; } internal float Extra { get; } internal float Random { get; }
|
||||
internal PlayerKVFishConfig(int id,int area,int quality,int card,float weight,float extra,float random)
|
||||
{ Id=id; Area=area; Quality=quality; Card=card; Weight=weight; Extra=extra; Random=random; }
|
||||
}
|
||||
internal sealed class PlayerKVFishCardConfig
|
||||
{
|
||||
internal int Id { get; } internal int MaxLevel { get; }
|
||||
internal IReadOnlyList<int> Required { get; } internal IReadOnlyList<float> Weights { get; }
|
||||
internal PlayerKVFishCardConfig(int id,int max,IEnumerable<int> required,IEnumerable<float> weights)
|
||||
{ Id=id; MaxLevel=max; Required=new List<int>(required).AsReadOnly(); Weights=new List<float>(weights).AsReadOnly(); }
|
||||
}
|
||||
internal sealed class PlayerKVFishValue
|
||||
{
|
||||
internal int Level { get; } internal float Weight { get; } internal int Cards { get; }
|
||||
internal int Number { get; } internal bool New { get; }
|
||||
internal IReadOnlyList<string> Compatibility { get; }
|
||||
internal PlayerKVFishValue(int level,float weight,int cards,int number,bool isNew,IEnumerable<string> compatibility=null)
|
||||
{ Level=level; Weight=weight; Cards=cards; Number=number; New=isNew; Compatibility=new List<string>(compatibility ?? Array.Empty<string>()).AsReadOnly(); }
|
||||
internal bool Same(PlayerKVFishValue other) => Level==other.Level && Weight==other.Weight && Cards==other.Cards && Number==other.Number && New==other.New;
|
||||
}
|
||||
internal sealed class PlayerKVRodAccessories
|
||||
{
|
||||
internal int Glove { get; } internal IReadOnlyDictionary<int,int> RodSkin { get; }
|
||||
internal IReadOnlyList<int> Gloves { get; } internal IReadOnlyList<int> Skins { get; }
|
||||
internal IReadOnlyList<int> NewGloves { get; } internal IReadOnlyList<int> NewSkins { get; }
|
||||
internal PlayerKVRodAccessories(int glove,IDictionary<int,int> rodSkin,IEnumerable<int> gloves,IEnumerable<int> skins,IEnumerable<int> newGloves,IEnumerable<int> newSkins)
|
||||
{ Glove=glove; RodSkin=new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(rodSkin)); Gloves=new List<int>(gloves).AsReadOnly();
|
||||
Skins=new List<int>(skins).AsReadOnly(); NewGloves=new List<int>(newGloves).AsReadOnly(); NewSkins=new List<int>(newSkins).AsReadOnly(); }
|
||||
}
|
||||
internal sealed class PlayerKVFishState
|
||||
{
|
||||
internal IReadOnlyDictionary<int,int> Rods { get; } internal IReadOnlyDictionary<int,int> RodLevels { get; }
|
||||
internal IReadOnlyDictionary<int,int> Swim { get; } internal IReadOnlyDictionary<int,int> SwimCount { get; }
|
||||
internal IReadOnlyDictionary<int,int> Progress { get; } internal IReadOnlyDictionary<int,int> Honor { get; }
|
||||
internal IReadOnlyDictionary<string,float> Luck { get; } internal PlayerKVRodAccessories Accessories { get; }
|
||||
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> RawMaps { get; }
|
||||
internal IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> Maps { get; }
|
||||
internal IReadOnlyDictionary<int,PlayerKVFishValue> Fish { get; } internal IReadOnlyDictionary<int,int> Cards { get; }
|
||||
static IReadOnlyDictionary<int,int> Copy(IDictionary<int,int> values) => new ReadOnlyDictionary<int,int>(new Dictionary<int,int>(values));
|
||||
static IReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>> CopyMaps(IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps) =>
|
||||
new ReadOnlyDictionary<int,IReadOnlyDictionary<int,PlayerKVFishValue>>(maps.ToDictionary(p=>p.Key,p=>(IReadOnlyDictionary<int,PlayerKVFishValue>)new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(p.Value))));
|
||||
internal PlayerKVFishState(IDictionary<int,int> rods,IDictionary<int,int> rodLevels,IDictionary<int,int> swim,IDictionary<int,int> swimCount,
|
||||
IDictionary<int,int> progress,IDictionary<int,int> honor,IDictionary<string,float> luck,PlayerKVRodAccessories accessories,
|
||||
IDictionary<int,Dictionary<int,PlayerKVFishValue>> rawMaps,IDictionary<int,Dictionary<int,PlayerKVFishValue>> maps,
|
||||
IDictionary<int,PlayerKVFishValue> fish,IDictionary<int,int> cards)
|
||||
{ Rods=Copy(rods); RodLevels=Copy(rodLevels); Swim=Copy(swim); SwimCount=Copy(swimCount); Progress=Copy(progress); Honor=Copy(honor);
|
||||
Luck=new ReadOnlyDictionary<string,float>(new Dictionary<string,float>(luck,StringComparer.Ordinal)); Accessories=accessories;
|
||||
RawMaps=CopyMaps(rawMaps); Maps=CopyMaps(maps); Fish=new ReadOnlyDictionary<int,PlayerKVFishValue>(new Dictionary<int,PlayerKVFishValue>(fish)); Cards=Copy(cards); }
|
||||
}
|
||||
internal sealed class PlayerKVFootPrintState
|
||||
{
|
||||
internal ulong Bits { get; }
|
||||
internal PlayerKVFootPrintState(ulong bits) { Bits = bits; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46103e7278684f2db18a8cb6340a4b76
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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 }
|
||||
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
|
||||
internal sealed class PlayerKVPrepareEvidence
|
||||
{
|
||||
internal PlayerKVLoginPrepareError Error { get; }
|
||||
internal PlayerKVLoginPrepareError OriginalError { get; }
|
||||
internal game.Account.AccountLoginError? AccountError { get; }
|
||||
internal PlayerKVGameOpenError? OpenError { get; }
|
||||
internal PlayerKVSessionOpenError? OwnerError { get; }
|
||||
internal PlayerKVError? KVError { get; }
|
||||
internal PlayerKVSyncStatus? SyncStatus { get; }
|
||||
internal int? HttpStatusCode { get; }
|
||||
internal string ExceptionType { get; }
|
||||
internal string CleanupExceptionType { get; }
|
||||
internal PlayerKVPrepareEvidence(PlayerKVLoginPrepareResult value)
|
||||
{ Error=value.Error; OriginalError=value.OriginalError; AccountError=value.AccountError; OpenError=value.OpenError;
|
||||
OwnerError=value.OwnerError; KVError=value.KVError; SyncStatus=value.SyncStatus; HttpStatusCode=value.HttpStatusCode;
|
||||
ExceptionType=value.ExceptionType; CleanupExceptionType=value.CleanupExceptionType; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadResult
|
||||
{
|
||||
internal PlayerKVGameLoadState State => Error==PlayerKVGameLoadError.None ? PlayerKVGameLoadState.LoadedForValidation : PlayerKVGameLoadState.Rejected;
|
||||
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 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; }
|
||||
}
|
||||
internal sealed class PlayerKVGameLoadCoordinator
|
||||
{
|
||||
readonly SemaphoreSlim loginGate=new SemaphoreSlim(1,1);
|
||||
readonly object closeGate=new object();
|
||||
readonly PlayerKVGameLoadTransaction transaction;
|
||||
readonly Func<bool> unstarted;
|
||||
readonly Action notify;
|
||||
readonly Func<int> intentCount;
|
||||
readonly Action<PlayerKVAccountLease> bind;
|
||||
readonly Func<Task> unbind;
|
||||
readonly PlayerKVShutdownRegistry registry;
|
||||
readonly int mainThread;
|
||||
readonly SynchronizationContext mainContext;
|
||||
PlayerKVLoginContext context;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
PlayerKVGameLoadCapability capability;
|
||||
Task closing;
|
||||
CancellationTokenRegistration lifetimeRegistration;
|
||||
Action clearAfterApply;
|
||||
long epoch;
|
||||
bool loaded;
|
||||
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,
|
||||
Action<PlayerKVAccountLease> bind,Func<Task> unbind,PlayerKVShutdownRegistry registry=null)
|
||||
{
|
||||
this.transaction=transaction ?? throw new ArgumentNullException(nameof(transaction));
|
||||
this.unstarted=unstarted ?? throw new ArgumentNullException(nameof(unstarted)); this.notify=notify ?? throw new ArgumentNullException(nameof(notify));
|
||||
this.intentCount=intentCount ?? throw new ArgumentNullException(nameof(intentCount)); this.bind=bind ?? throw new ArgumentNullException(nameof(bind));
|
||||
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)
|
||||
{
|
||||
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
|
||||
return Result(PlayerKVGameLoadError.Input);
|
||||
if(!await EnterGate(next,token)) return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy);
|
||||
PlayerKVLoginPrepareResult prepared=null; PlayerKVSchemaVerdict? verdict=null;
|
||||
long attempt=0;
|
||||
try
|
||||
{
|
||||
// An earlier successful validation must also be explicitly revoked before replacing its account.
|
||||
if(context!=null) _=Revoke();
|
||||
var previous=await registry.ObserveAsync(next.LoginObserveMs,token,next.Delay);
|
||||
if(previous!=PlayerKVShutdownStatus.Ready) return Result(previous==PlayerKVShutdownStatus.Failed ? PlayerKVGameLoadError.PreviousCloseFailed :
|
||||
previous==PlayerKVShutdownStatus.Cancelled ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.PreviousClosePending);
|
||||
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()));
|
||||
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); });
|
||||
var work=context.PrepareAsync(token);
|
||||
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using(token.Register(()=>cancelled.TrySetResult(true)))
|
||||
{
|
||||
if(await Task.WhenAny(work,cancelled.Task)!=work)
|
||||
return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
prepared=await work;
|
||||
}
|
||||
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);
|
||||
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);
|
||||
if(!context.TryAcquireAccountLease(next.LeaseCapability,out var lease)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
|
||||
bind(lease);
|
||||
PlayerKVApplyError applied;
|
||||
try { applied=transaction.Apply(decoded.Data,()=>context.IsCurrent && epoch==attempt && unstarted(),()=>guard.IsCurrent && guard.Violations==0,notify); }
|
||||
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());
|
||||
}
|
||||
catch { return await Reject(PlayerKVGameLoadError.Apply,attempt,next,prepared,verdict); }
|
||||
finally { loginGate.Release(); }
|
||||
}
|
||||
async Task<bool> EnterGate(PlayerKVGameLoadCapability next,CancellationToken token)
|
||||
{
|
||||
using(var cancellation=CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
{
|
||||
var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token);
|
||||
bool acquired=false,accepted=false;
|
||||
try
|
||||
{
|
||||
if(await Task.WhenAny(wait,deadline)==wait) { await wait; acquired=true; accepted=!token.IsCancellationRequested && !deadline.IsCompleted; return accepted; }
|
||||
cancellation.Cancel();
|
||||
try { await wait; acquired=true; } catch(OperationCanceledException) { }
|
||||
return false;
|
||||
}
|
||||
catch(OperationCanceledException) { return false; }
|
||||
finally
|
||||
{
|
||||
cancellation.Cancel();
|
||||
// A lock won at the timeout/cancel boundary belongs to this attempt and must be returned.
|
||||
if(acquired && !accepted) loginGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
internal Task Revoke(long? expectedEpoch=null)
|
||||
{
|
||||
lock(closeGate)
|
||||
{
|
||||
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask;
|
||||
if(closing!=null) return closing;
|
||||
loaded=false; Interlocked.Increment(ref epoch);
|
||||
if(context==null && guard==null) return Task.CompletedTask;
|
||||
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
closing=completion.Task;
|
||||
var owner=context; var leaseGuard=guard; var source=capability;
|
||||
// Context cancellation is synchronous and occurs even if the main-thread reset is queued.
|
||||
var drain=owner?.CloseAsync() ?? Task.CompletedTask;
|
||||
var cleared=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
void Clear()
|
||||
{
|
||||
try
|
||||
{
|
||||
var detached=unbind();
|
||||
if(!transaction.Clear()) throw new InvalidOperationException("Controlled container is poisoned.");
|
||||
CompleteClear(detached,cleared);
|
||||
}
|
||||
catch(Exception ex) { cleared.TrySetException(ex); }
|
||||
}
|
||||
if(Thread.CurrentThread.ManagedThreadId==mainThread && transaction.IsApplying) clearAfterApply=Clear;
|
||||
else if(Thread.CurrentThread.ManagedThreadId==mainThread) Clear();
|
||||
else if(mainContext!=null) mainContext.Post(_=>Clear(),null);
|
||||
else cleared.TrySetException(new InvalidOperationException("Main-thread cleanup unavailable."));
|
||||
_=FinishClose(drain,cleared.Task,leaseGuard,source,completion);
|
||||
registry.Register(drain,owner);
|
||||
registry.Register(cleared.Task,owner);
|
||||
registry.Register(closing,owner);
|
||||
return closing;
|
||||
}
|
||||
}
|
||||
static async void CompleteClear(Task detached,TaskCompletionSource<bool> cleared)
|
||||
{ try { await detached; cleared.TrySetResult(true); } catch(Exception ex) { cleared.TrySetException(ex); } }
|
||||
static async Task FinishClose(Task drain,Task clear,PlayerKVGameLoadGuard leaseGuard,PlayerKVGameLoadCapability source,TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(drain,clear).ConfigureAwait(false); source?.CloseUnused(); leaseGuard?.Dispose(); completion.TrySetResult(true);
|
||||
}
|
||||
catch(Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original,long attempt,PlayerKVGameLoadCapability source,
|
||||
PlayerKVLoginPrepareResult prepare,PlayerKVSchemaVerdict? verdict)
|
||||
{
|
||||
var close=Revoke();
|
||||
var status=await registry.ObserveAsync(source.LoginObserveMs,CancellationToken.None,source.Delay);
|
||||
var error=status==PlayerKVShutdownStatus.Failed || close.IsFaulted ? PlayerKVGameLoadError.CloseFailed :
|
||||
!close.IsCompleted ? PlayerKVGameLoadError.ClosePending : original;
|
||||
return new PlayerKVGameLoadResult(error,attempt,original:prepare?.CleanupFailed==true ? PlayerKVGameLoadError.Prepare : original,verdict:verdict,prepare:prepare);
|
||||
}
|
||||
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch));
|
||||
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 097cfee9d4554f3fadd160787f0857af
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVGuardPoint { Compare, ReadLocal, Update, Immediate, Sync, Lifecycle, PrefsSave }
|
||||
internal sealed class PlayerKVLoadSideEffectException : InvalidOperationException
|
||||
{ internal PlayerKVLoadSideEffectException() : base("KV controlled load rejected a legacy side effect.") { } }
|
||||
internal sealed class PlayerKVGameLoadGuard : IDisposable
|
||||
{
|
||||
static readonly object gate=new object();
|
||||
static PlayerKVGameLoadGuard owner;
|
||||
readonly int[] counts=new int[7];
|
||||
int violations;
|
||||
bool disposed;
|
||||
private PlayerKVGameLoadGuard() { }
|
||||
internal static bool TryEnter(out PlayerKVGameLoadGuard lease)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
lease=null; if(owner!=null) return false;
|
||||
owner=lease=new PlayerKVGameLoadGuard(); return true;
|
||||
}
|
||||
}
|
||||
internal bool IsCurrent { get { lock(gate) return !disposed && ReferenceEquals(owner,this); } }
|
||||
internal int Violations => Volatile.Read(ref violations);
|
||||
internal int Count(PlayerKVGuardPoint point) => Volatile.Read(ref counts[(int)point]);
|
||||
internal static bool Reject(PlayerKVGuardPoint point)
|
||||
{
|
||||
lock(gate)
|
||||
{
|
||||
if(owner==null) return false;
|
||||
Interlocked.Increment(ref owner.counts[(int)point]); Interlocked.Increment(ref owner.violations); return true;
|
||||
}
|
||||
}
|
||||
internal static void RequireLegacyAllowed(PlayerKVGuardPoint point)
|
||||
{ if(Reject(point)) throw new PlayerKVLoadSideEffectException(); }
|
||||
public void Dispose()
|
||||
{
|
||||
lock(gate) { if(disposed) return; disposed=true; if(ReferenceEquals(owner,this)) owner=null; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 90067312ead44486b7d1c9694e2a0814
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal interface IPlayerKVLoadModule
|
||||
{
|
||||
int Id { get; }
|
||||
void Reset();
|
||||
void Replace(PlayerKVDecodedSave decoded);
|
||||
}
|
||||
internal sealed class PlayerKVLoadModule : IPlayerKVLoadModule
|
||||
{
|
||||
readonly Action reset;
|
||||
readonly Action<PlayerKVDecodedSave> replace;
|
||||
public int Id { get; }
|
||||
internal PlayerKVLoadModule(int id,Action reset,Action<PlayerKVDecodedSave> replace)
|
||||
{ Id=id; this.reset=reset ?? throw new ArgumentNullException(nameof(reset)); this.replace=replace ?? throw new ArgumentNullException(nameof(replace)); }
|
||||
public void Reset() => reset();
|
||||
public void Replace(PlayerKVDecodedSave decoded) => replace(decoded);
|
||||
}
|
||||
internal enum PlayerKVApplyError { None, Input, Stale, Apply, SideEffect, Poisoned }
|
||||
internal sealed class PlayerKVGameLoadTransaction
|
||||
{
|
||||
readonly IPlayerKVLoadModule[] modules;
|
||||
readonly int mainThread;
|
||||
readonly Action discard;
|
||||
bool applying;
|
||||
internal bool IsApplying => applying;
|
||||
internal bool Poisoned { get; private set; }
|
||||
internal int AppliedModules { get; private set; }
|
||||
internal PlayerKVGameLoadTransaction(IEnumerable<IPlayerKVLoadModule> modules,int mainThread,Action discard)
|
||||
{
|
||||
this.modules=modules?.ToArray() ?? throw new ArgumentNullException(nameof(modules));
|
||||
if(this.modules.Length!=31 || !this.modules.Select(m=>m?.Id ?? 0).SequenceEqual(Enumerable.Range(1,31)))
|
||||
throw new ArgumentException("Exactly 31 ordered modules required.");
|
||||
this.mainThread=mainThread; this.discard=discard ?? throw new ArgumentNullException(nameof(discard));
|
||||
}
|
||||
// Synchronous main-thread region: no callback may yield between Reset and publication.
|
||||
internal PlayerKVApplyError Apply(PlayerKVDecodedSave decoded,Func<bool> current,Func<bool> clean,Action notify)
|
||||
{
|
||||
if(Poisoned) return PlayerKVApplyError.Poisoned;
|
||||
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread || decoded==null || current==null || clean==null || notify==null)
|
||||
return PlayerKVApplyError.Input;
|
||||
applying=true; AppliedModules=0;
|
||||
PlayerKVApplyError error=PlayerKVApplyError.Apply;
|
||||
try
|
||||
{
|
||||
if(!current()) error=PlayerKVApplyError.Stale;
|
||||
else if(!clean()) error=PlayerKVApplyError.SideEffect;
|
||||
else if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
|
||||
else
|
||||
{
|
||||
foreach(var module in modules)
|
||||
{
|
||||
if(!current()) { error=PlayerKVApplyError.Stale; break; }
|
||||
if(!clean()) { error=PlayerKVApplyError.SideEffect; break; }
|
||||
module.Replace(decoded); AppliedModules++;
|
||||
}
|
||||
if(AppliedModules==31)
|
||||
{
|
||||
// The last module can revoke the epoch or trip a guard; never publish in that gap.
|
||||
if(!current()) error=PlayerKVApplyError.Stale;
|
||||
else if(!clean()) error=PlayerKVApplyError.SideEffect;
|
||||
else
|
||||
{
|
||||
notify();
|
||||
error=!current() ? PlayerKVApplyError.Stale : !clean() ? PlayerKVApplyError.SideEffect : PlayerKVApplyError.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { error=PlayerKVApplyError.Apply; }
|
||||
finally { applying=false; }
|
||||
if(error!=PlayerKVApplyError.None)
|
||||
{
|
||||
if(!ResetAll()) error=PlayerKVApplyError.Poisoned;
|
||||
try { discard(); } catch { Poisoned=true; error=PlayerKVApplyError.Poisoned; }
|
||||
AppliedModules=0;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
internal bool Clear()
|
||||
{
|
||||
if(applying || Thread.CurrentThread.ManagedThreadId!=mainThread) throw new InvalidOperationException("Main thread required.");
|
||||
bool cleared=ResetAll();
|
||||
try { discard(); } catch { Poisoned=true; cleared=false; }
|
||||
AppliedModules=0; return cleared;
|
||||
}
|
||||
bool ResetAll()
|
||||
{
|
||||
bool ok=true;
|
||||
foreach(var module in modules) try { module.Reset(); } catch { ok=false; }
|
||||
if(!ok) Poisoned=true;
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c1ea0dd4977649169863e3c917802188
|
||||
@@ -0,0 +1,276 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVGameOpenError { None, OwnerRejected, Cancelled, Stale, CloseFailed }
|
||||
|
||||
public sealed class PlayerKVGameOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVGameOpenError.None;
|
||||
public PlayerKVGameOpenError Error { get; }
|
||||
public PlayerKVSessionOpenError OwnerError { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVGameSessionHandle Handle { get; }
|
||||
internal PlayerKVGameOpenResult(PlayerKVGameOpenError error,
|
||||
PlayerKVSessionOpenError ownerError = PlayerKVSessionOpenError.None,
|
||||
PlayerKVError transportError = PlayerKVError.None, PlayerKVGameSessionHandle handle = null)
|
||||
{ Error = error; OwnerError = ownerError; TransportError = transportError; Handle = handle; }
|
||||
}
|
||||
|
||||
/// <summary>Owns the KV owner, but never disposes the supplied login session.</summary>
|
||||
public sealed class PlayerKVGameSessionBridge
|
||||
{
|
||||
// A fixed slot is also the handle's identity. It can never be rebound to another owner.
|
||||
internal sealed class Slot
|
||||
{
|
||||
internal readonly CancellationTokenSource Lifetime;
|
||||
internal readonly Func<long, bool> ExternalCurrent;
|
||||
internal readonly long Epoch;
|
||||
readonly S101AccountSession session; readonly CookieContainer cookies;
|
||||
internal PlayerKVSessionOwner Owner;
|
||||
internal Task<PlayerKVSessionOpenResult> Creation;
|
||||
internal Task Closing;
|
||||
readonly object cancellationGate = new object();
|
||||
bool lifetimeDisposed; Exception cancellationFailure;
|
||||
internal Exception CancellationFailure { get { lock (cancellationGate) return cancellationFailure; } }
|
||||
int revoked;
|
||||
internal Slot(S101AccountSession session, long epoch, Func<long, bool> current, CancellationToken token)
|
||||
{ this.session = session; cookies = session?.Cookies; Epoch = epoch; ExternalCurrent = current; Lifetime = CancellationTokenSource.CreateLinkedTokenSource(token); }
|
||||
internal void DisposeLifetime()
|
||||
{
|
||||
lock (cancellationGate)
|
||||
{
|
||||
if (lifetimeDisposed) return;
|
||||
Lifetime.Dispose(); lifetimeDisposed = true;
|
||||
}
|
||||
}
|
||||
internal bool Revoked => Volatile.Read(ref revoked) != 0;
|
||||
internal void Revoke() => Interlocked.Exchange(ref revoked, 1);
|
||||
internal bool IsCurrent(long epoch) =>
|
||||
!Revoked && !Lifetime.IsCancellationRequested && session != null && session.IsAuthenticated &&
|
||||
ReferenceEquals(session.Cookies, cookies) && epoch == Epoch && ExternalCurrent(epoch);
|
||||
internal void Cancel()
|
||||
{
|
||||
Revoke();
|
||||
// Close can revoke an opening slot while Create is completing its cleanup.
|
||||
// Serialize CTS disposal with cancellation, without holding the bridge monitor.
|
||||
lock (cancellationGate)
|
||||
{
|
||||
if (lifetimeDisposed) return;
|
||||
try { Lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sealed class OpenRequest
|
||||
{
|
||||
internal bool Entered;
|
||||
internal CancellationTokenRegistration Registration;
|
||||
internal readonly TaskCompletionSource<PlayerKVGameOpenResult> Result =
|
||||
new TaskCompletionSource<PlayerKVGameOpenResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
readonly object gate = new object();
|
||||
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
|
||||
Task tail = Task.CompletedTask;
|
||||
Task lastClose;
|
||||
Slot active;
|
||||
long closeGeneration;
|
||||
bool closeFailed;
|
||||
|
||||
public PlayerKVGameSessionBridge() : this(jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
|
||||
|
||||
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory)
|
||||
{ this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory)); }
|
||||
|
||||
public Task<PlayerKVGameOpenResult> OpenAsync(S101AccountSession session, string cacheRoot,
|
||||
string playerId, Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken lifetimeToken = default)
|
||||
{
|
||||
if (isCurrent == null)
|
||||
return Task.FromResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected,
|
||||
PlayerKVSessionOpenError.Validation));
|
||||
var request = new OpenRequest();
|
||||
var done = NewCompletion();
|
||||
Task predecessor; long generation;
|
||||
lock (gate)
|
||||
{
|
||||
predecessor = tail; tail = done.Task;
|
||||
generation = closeGeneration;
|
||||
lastClose = null;
|
||||
}
|
||||
request.Registration = lifetimeToken.Register(() =>
|
||||
{
|
||||
lock (gate)
|
||||
if (!request.Entered)
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.Cancelled));
|
||||
});
|
||||
// The reserved tail stays pending even if the caller's queued wait is cancelled.
|
||||
_ = OpenCoreAsync(predecessor, done, request, generation, session, cacheRoot,
|
||||
playerId, api, epoch, isCurrent, lifetimeToken);
|
||||
return request.Result.Task;
|
||||
}
|
||||
|
||||
async Task OpenCoreAsync(Task predecessor, TaskCompletionSource<bool> done, OpenRequest request,
|
||||
long generation, S101AccountSession session, string cacheRoot, string playerId,
|
||||
Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken token)
|
||||
{
|
||||
try
|
||||
{
|
||||
await predecessor.ConfigureAwait(false);
|
||||
Slot previous;
|
||||
lock (gate)
|
||||
{
|
||||
request.Entered = true;
|
||||
if (closeFailed) { Complete(request, PlayerKVGameOpenError.CloseFailed); return; }
|
||||
if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; }
|
||||
if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; }
|
||||
previous = active;
|
||||
previous?.Revoke();
|
||||
}
|
||||
if (previous != null) await CloseSlotAsync(previous).ConfigureAwait(false);
|
||||
|
||||
Slot slot;
|
||||
lock (gate)
|
||||
{
|
||||
if (token.IsCancellationRequested) { Complete(request, PlayerKVGameOpenError.Cancelled); return; }
|
||||
if (generation != closeGeneration) { Complete(request, PlayerKVGameOpenError.Stale); return; }
|
||||
slot = new Slot(session, epoch, isCurrent, token);
|
||||
active = slot;
|
||||
}
|
||||
slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api,
|
||||
epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory);
|
||||
var opened = await slot.Creation.ConfigureAwait(false);
|
||||
if (!opened.Success)
|
||||
{
|
||||
// CreateAsync returns failure only after its failed owner has drained.
|
||||
if (slot.CancellationFailure != null) throw slot.CancellationFailure;
|
||||
lock (gate) if (ReferenceEquals(active, slot)) active = null;
|
||||
slot.Revoke(); slot.DisposeLifetime();
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.OwnerRejected,
|
||||
opened.Error, opened.TransportError));
|
||||
return;
|
||||
}
|
||||
slot.Owner = opened.Owner;
|
||||
var valid = slot.IsCurrent(epoch);
|
||||
lock (gate)
|
||||
{
|
||||
if (valid && generation == closeGeneration && !slot.Revoked && !token.IsCancellationRequested)
|
||||
{
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(PlayerKVGameOpenError.None,
|
||||
handle: new PlayerKVGameSessionHandle(slot)));
|
||||
return;
|
||||
}
|
||||
slot.Revoke();
|
||||
}
|
||||
await CloseSlotAsync(slot).ConfigureAwait(false);
|
||||
Complete(request, token.IsCancellationRequested ? PlayerKVGameOpenError.Cancelled : PlayerKVGameOpenError.Stale);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Includes failed Create cleanup. Retain the slot, Creation/Closing task and owner;
|
||||
// no later Open is allowed to pretend the cache is reusable.
|
||||
lock (gate) { closeFailed = true; active?.Revoke(); }
|
||||
Complete(request, PlayerKVGameOpenError.CloseFailed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
request.Registration.Dispose();
|
||||
done.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Immediately revokes admission, then drains every preceding lifecycle operation.</summary>
|
||||
public Task CloseAsync()
|
||||
{
|
||||
Task predecessor; Slot observed;
|
||||
var done = NewCompletion();
|
||||
var result = NewCompletion();
|
||||
lock (gate)
|
||||
{
|
||||
if (lastClose != null) return lastClose;
|
||||
closeGeneration++;
|
||||
observed = active; observed?.Revoke();
|
||||
predecessor = tail; tail = done.Task; lastClose = result.Task;
|
||||
}
|
||||
// Cancellation callbacks must never run while the bridge monitor is held.
|
||||
observed?.Cancel();
|
||||
_ = CloseCoreAsync(predecessor, done, result);
|
||||
return result.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(Task predecessor, TaskCompletionSource<bool> done, TaskCompletionSource<bool> result)
|
||||
{
|
||||
try
|
||||
{
|
||||
await predecessor.ConfigureAwait(false);
|
||||
Slot slot;
|
||||
lock (gate)
|
||||
{
|
||||
if (closeFailed) throw new InvalidOperationException("KV owner cleanup previously failed.");
|
||||
slot = active;
|
||||
}
|
||||
if (slot != null) await CloseSlotAsync(slot).ConfigureAwait(false);
|
||||
result.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lock (gate) { closeFailed = true; active?.Revoke(); }
|
||||
result.TrySetException(ex);
|
||||
}
|
||||
finally { done.TrySetResult(true); }
|
||||
}
|
||||
|
||||
async Task CloseSlotAsync(Slot slot)
|
||||
{
|
||||
slot.Cancel();
|
||||
if (slot.Owner != null)
|
||||
{
|
||||
slot.Closing = slot.Closing ?? slot.Owner.CloseAsync();
|
||||
await slot.Closing.ConfigureAwait(false);
|
||||
}
|
||||
if (slot.CancellationFailure != null) throw slot.CancellationFailure;
|
||||
lock (gate) if (ReferenceEquals(active, slot)) active = null;
|
||||
slot.DisposeLifetime();
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> NewCompletion() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
static void Complete(OpenRequest request, PlayerKVGameOpenError error) =>
|
||||
request.Result.TrySetResult(new PlayerKVGameOpenResult(error));
|
||||
}
|
||||
|
||||
/// <summary>Captured by delayed callbacks; all operations target this original owner only.</summary>
|
||||
public sealed class PlayerKVGameSessionHandle
|
||||
{
|
||||
readonly PlayerKVGameSessionBridge.Slot slot;
|
||||
internal PlayerKVGameSessionHandle(PlayerKVGameSessionBridge.Slot slot) { this.slot = slot; }
|
||||
PlayerKVSessionOwner Owner => slot.Owner;
|
||||
public string OwnerInstanceId => Owner.OwnerInstanceId;
|
||||
public string PlayerId => Owner.PlayerId;
|
||||
public long Epoch => Owner.Epoch;
|
||||
public PlayerKVSyncStatus Status => Owner.Status;
|
||||
|
||||
public PlayerKVLocalReadResult ReadLocalSnapshot() => slot.Revoked
|
||||
? new PlayerKVLocalReadResult(PlayerKVLocalReadError.StaleOrClosing, OwnerInstanceId, PlayerId, Epoch)
|
||||
: Owner.ReadLocalSnapshot();
|
||||
|
||||
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true) =>
|
||||
slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore);
|
||||
public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key);
|
||||
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default) => slot.Revoked
|
||||
? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status))
|
||||
: Owner.FlushAsync(waitToken);
|
||||
public Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token = default) =>
|
||||
!slot.IsCurrent(Epoch) ? Task.FromResult(new PlayerKVTicketResult(
|
||||
PlayerKVTicketStatus.Blocked, PlayerKVError.Cancelled, Owner.Status)) : Owner.WaitForTicketAsync(ticket, token);
|
||||
static PlayerKVEditResult BlockedEdit() =>
|
||||
new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ebfda20b77ad4dc1ac25f16acffb9995
|
||||
@@ -0,0 +1,60 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using asap.core;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Bind the existing game container. Missing registrations fail closed; no parallel fixture objects.
|
||||
internal static class PlayerKVGameplayBindings
|
||||
{
|
||||
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)
|
||||
{
|
||||
var tables=Require<cfg.Tables>();
|
||||
return new PlayerKVSchemaSnapshot("kv-loop",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),
|
||||
tables.TbFishingEvent.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVEventConfig(p.Value.RedirectID,(p.Value.TimeDefinition as cfg.LimitedTime)?.EndTime)),tables.TbGlobalConfig.GlobalTimeOffset,
|
||||
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)
|
||||
{
|
||||
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>();
|
||||
var achievements=Require<AchievementDataManager>(); var box=Require<FishingBoxData>(); var guide=Require<GameCore.GuideDataCenter>(); var battle=Require<BattlePassData>();
|
||||
var sign=Require<GameCore.SigninData>(); var trigger=Require<GameCore.TriggerPackData>(); var club=Require<ClubService>(); var challenge=Require<FishingChallengeManager>();
|
||||
var chains=Require<DataCenter.OfferChainsChestManager>(); var disco=Require<DataCenter.OfferDiscoPartyManager>(); var partner=Require<DataCenter.EventPartnerGatherManager>(); var cube=Require<EventCubeMeltManager>();
|
||||
var eventPack=Require<EventPackData>(); var piggy=Require<PiggyBankPackData>(); var bargain=Require<BargainPackData>(); var foot=Require<IFootPrintService>() as FootPrintService ?? throw new InvalidOperationException("M23 FootPrint binding mismatch."); var shooting=Require<EventShootingRangeData>();
|
||||
var potion=Require<EventPotionDataManager>(); var pinball=Require<EventPinballDataManager>(); var scratch=Require<EventScratchTicketDataManager>();
|
||||
var progress=eventBase.ThanksGivingPackData; var migration=eventBase.ChainPackWithProgressMigrationData;
|
||||
var pinata=Require<EventPinataPackDataManager>(); var digging=Require<Script.RuntimeScript.DiggingGameManager>();
|
||||
var modules=new List<IPlayerKVLoadModule>();
|
||||
void Add<T>(int id,Action reset,Action<T> replace) where T:class => modules.Add(new PlayerKVLoadModule(id,reset,d=>replace(d.Module<T>(id))));
|
||||
Add<PlayerKVPlayerState>(1,player.ResetForKVLoad,player.ReplaceFromKVDecoded); Add<PlayerKVCoreState>(2,small.ResetForKVLoad,small.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVFishState>(3,fish.ResetForKVLoad,fish.ReplaceFromKVDecoded); Add<PlayerKVShopState>(4,shop.ResetForKVLoad,shop.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVEventBaseState>(5,eventBase.ResetForKVLoad,eventBase.ReplaceFromKVDecoded); Add<PlayerKVBuffState>(6,buffs.ResetForKVLoad,buffs.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVCampState>(7,camp.ResetForKVLoad,camp.ReplaceFromKVDecoded); Add<PlayerKVAchievementState>(8,achievements.ResetForKVLoad,achievements.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVIntMapState>(9,box.ResetForKVLoad,box.ReplaceFromKVDecoded); Add<PlayerKVGuideState>(10,guide.ResetForKVLoad,guide.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVBattlePassState>(11,battle.ResetForKVLoad,battle.ReplaceFromKVDecoded); Add<PlayerKVMonthlySignState>(12,sign.ResetForKVLoad,sign.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVTriggerState>(13,trigger.ResetForKVLoad,trigger.ReplaceFromKVDecoded); Add<PlayerKVClubState>(14,club.ResetForKVLoad,club.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVChallengeState>(15,challenge.ResetForKVLoad,challenge.ReplaceFromKVDecoded); Add<PlayerKVOfferState>(16,chains.ResetForKVLoad,chains.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVOfferState>(17,disco.ResetForKVLoad,disco.ReplaceFromKVDecoded); Add<PlayerKVPartnerState>(18,partner.ResetForKVLoad,partner.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVCubeState>(19,cube.ResetForKVLoad,cube.ReplaceFromKVDecoded); Add<PlayerKVEventPackState>(20,eventPack.ResetForKVLoad,eventPack.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVPiggyBankState>(21,piggy.ResetForKVLoad,piggy.ReplaceFromKVDecoded); Add<PlayerKVBargainState>(22,bargain.ResetForKVLoad,bargain.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVFootPrintState>(23,foot.ResetForKVLoad,foot.ReplaceFromKVDecoded); Add<PlayerKVShootingRangeState>(24,shooting.ResetForKVLoad,shooting.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVPotionState>(25,potion.ResetForKVLoad,potion.ReplaceFromKVDecoded); Add<PlayerKVPinballState>(26,pinball.ResetForKVLoad,pinball.ReplaceFromKVDecoded);
|
||||
Add<PlayerKVScratchState>(27,scratch.ResetForKVLoad,scratch.ReplaceFromKVDecoded);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7803da4435ea45c28007ff0198c676fb
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Only two approved in-memory normalizations. Never a persistence receipt or retry queue.
|
||||
internal sealed class PlayerKVInitializationIntents
|
||||
{
|
||||
readonly Dictionary<string,string> values=new Dictionary<string,string>(StringComparer.Ordinal);
|
||||
readonly string channel;
|
||||
internal long Epoch { get; }
|
||||
internal bool Discarded { get; private set; }
|
||||
internal int DiscardedCount { get; private set; }
|
||||
internal int Count => values.Count;
|
||||
internal IReadOnlyDictionary<string,string> Snapshot => new ReadOnlyDictionary<string,string>(new Dictionary<string,string>(values));
|
||||
internal PlayerKVInitializationIntents(long epoch,PlayerKVIdentityChannel channel)
|
||||
{
|
||||
if(!Enum.IsDefined(typeof(PlayerKVIdentityChannel),channel)) throw new ArgumentException("Unknown intent channel.");
|
||||
Epoch=epoch; this.channel=channel==PlayerKVIdentityChannel.hwGuest || channel==PlayerKVIdentityChannel.none ? null : channel.ToString();
|
||||
}
|
||||
internal void Add(long epoch,string key,string value)
|
||||
{
|
||||
if(Discarded || epoch!=Epoch || key==null || (key!="MMTest" && key!=channel) || value==null)
|
||||
throw new InvalidOperationException("Initialization intent rejected.");
|
||||
values[key]=value;
|
||||
}
|
||||
internal void Discard() { if(Discarded) return; DiscardedCount=values.Count; values.Clear(); Discarded=true; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6215961788794950aff0709dbb499793
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVKeyReadError { None, InvalidInput, LoadGuardActive, StaleOrClosing, NotReady }
|
||||
|
||||
/// <summary>
|
||||
/// A detached key observation, not authority for a later gameplay mutation.
|
||||
/// Found=null means failure; Found=false means a valid snapshot lacked the key;
|
||||
/// Found=true means a value exists, including the empty string.
|
||||
/// </summary>
|
||||
internal sealed class PlayerKVKeyReadResult
|
||||
{
|
||||
internal PlayerKVKeyReadError Error { get; }
|
||||
internal bool Success => Error == PlayerKVKeyReadError.None;
|
||||
internal bool? Found { get; }
|
||||
internal string Value { get; }
|
||||
internal string OwnerInstanceId { get; }
|
||||
internal string PlayerId { get; }
|
||||
internal long? Epoch { get; }
|
||||
|
||||
internal PlayerKVKeyReadResult(PlayerKVKeyReadError error, PlayerKVGameSessionHandle handle,
|
||||
bool? found = null, string value = null)
|
||||
{
|
||||
if (error == PlayerKVKeyReadError.None && (!found.HasValue || (found.Value && value == null)))
|
||||
throw new ArgumentException("A successful key observation requires an existence verdict.");
|
||||
Error = error;
|
||||
OwnerInstanceId = handle?.OwnerInstanceId;
|
||||
PlayerId = handle?.PlayerId;
|
||||
Epoch = handle?.Epoch;
|
||||
Found = Success ? found : null;
|
||||
Value = Success && found == true ? value : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 916120c58a7f4f71bf59e17a4a045bb8
|
||||
@@ -0,0 +1,30 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVLocalReadError { None, StaleOrClosing, NotReady }
|
||||
|
||||
/// <summary>A detached snapshot. Failed reads have no data, never a pretend empty save.</summary>
|
||||
public sealed class PlayerKVLocalReadResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVLocalReadError.None;
|
||||
public PlayerKVLocalReadError Error { get; }
|
||||
public string OwnerInstanceId { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public IReadOnlyDictionary<string, string> Data { get; }
|
||||
|
||||
internal PlayerKVLocalReadResult(PlayerKVLocalReadError error, string ownerId,
|
||||
string playerId, long epoch, IReadOnlyDictionary<string, string> data = null)
|
||||
{
|
||||
Error = error; OwnerInstanceId = ownerId; PlayerId = playerId; Epoch = epoch;
|
||||
if (error != PlayerKVLocalReadError.None) return;
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var item in data) copy.Add(item.Key, item.Value);
|
||||
Data = new ReadOnlyDictionary<string, string>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4bbcb1b86b2b4301af3d7518408edd6f
|
||||
@@ -0,0 +1,472 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns exactly one authenticated session and its KV bridge. Does not initialize gameplay or write.
|
||||
/// The caller must await the previous context's CloseAsync before creating the next context.
|
||||
/// A failed Close must never be bypassed by constructing another instance.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVLoginContext
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly string requestedId, cacheRoot;
|
||||
readonly long epoch;
|
||||
readonly Uri api;
|
||||
readonly Func<long, bool> externalCurrent;
|
||||
readonly Func<S101AccountSession> sessionFactory;
|
||||
readonly Func<PlayerKVGameSessionBridge> bridgeFactory;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
readonly TaskCompletionSource<bool> workSettled = NewSignal();
|
||||
TaskCompletionSource<PlayerKVLoginPrepareResult> preparation;
|
||||
TaskCompletionSource<bool> closing;
|
||||
CancellationToken firstToken;
|
||||
CancellationTokenRegistration registration;
|
||||
S101AccountSession session;
|
||||
CookieContainer cookies;
|
||||
PlayerKVGameSessionBridge bridge;
|
||||
PlayerKVGameSessionHandle handle;
|
||||
PlayerKVAccountDescriptor accountDescriptor;
|
||||
PlayerKVAccountLease accountLease;
|
||||
bool leaseIssued;
|
||||
string playerId, predicateExceptionType;
|
||||
int revoked, prepared, differentTokenCount;
|
||||
|
||||
// Internal factories are only for synthetic transport/cleanup faults. No public session or handle escapes.
|
||||
public PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent)
|
||||
: this(playerId, cacheRoot, epoch, isCurrent, new Uri(S101AccountSession.DefaultApiUrl),
|
||||
() => new S101AccountSession(), () => new PlayerKVGameSessionBridge()) { }
|
||||
|
||||
internal PlayerKVLoginContext(string playerId, string cacheRoot, long epoch, Func<long, bool> isCurrent,
|
||||
Uri api, Func<S101AccountSession> sessionFactory, Func<PlayerKVGameSessionBridge> bridgeFactory)
|
||||
{
|
||||
requestedId = playerId; this.cacheRoot = cacheRoot; this.epoch = epoch; externalCurrent = isCurrent;
|
||||
this.api = api; this.sessionFactory = sessionFactory; this.bridgeFactory = bridgeFactory;
|
||||
}
|
||||
|
||||
internal int DifferentTokenCount => Volatile.Read(ref differentTokenCount);
|
||||
|
||||
internal PlayerKVAccountDescriptor AccountDescriptor =>
|
||||
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
|
||||
|
||||
// All issuance and Close admission share gate. The editor factory may not block on Close.
|
||||
internal bool TryAcquireAccountLease(PlayerKVControlledLoadCapability capability,
|
||||
out PlayerKVAccountLease lease)
|
||||
{
|
||||
lease = null;
|
||||
if (capability == null || !IsCurrent) return false;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || revoked != 0 || prepared == 0 || leaseIssued) return false;
|
||||
leaseIssued = true;
|
||||
accountLease = new PlayerKVAccountLease(api, epoch, cookies, capability, () => IsCurrent,
|
||||
() => Volatile.Read(ref revoked) == 0);
|
||||
// A synthetic factory can synchronously revoke this context while being invoked.
|
||||
if (revoked != 0 || closing != null) return false;
|
||||
lease = accountLease;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Historical Prepared results and their data survive Close, but their authority does not.</summary>
|
||||
public bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Volatile.Read(ref prepared) == 0 || !EpochCurrent(epoch)) return false;
|
||||
var observed = session;
|
||||
return observed != null && observed.IsAuthenticated && observed.PlayerId == playerId &&
|
||||
ReferenceEquals(observed.Cookies, cookies) && Volatile.Read(ref revoked) == 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One task per instance. Only the FIRST token owns the lifetime; a different token on later calls
|
||||
/// is diagnosed but never replaces it. Even after Close this returns the original historical task.
|
||||
/// Cancellation/failure does not complete until transport and Close have really drained. A transport
|
||||
/// ignoring cancellation can hold this task indefinitely; observe CloseAsync, never bypass its barrier.
|
||||
/// </summary>
|
||||
public Task<PlayerKVLoginPrepareResult> PrepareAsync(CancellationToken lifetimeToken = default)
|
||||
{
|
||||
Task<PlayerKVLoginPrepareResult> result;
|
||||
bool start, alreadyClosing;
|
||||
lock (gate)
|
||||
{
|
||||
start = preparation == null;
|
||||
if (start)
|
||||
{
|
||||
preparation = new TaskCompletionSource<PlayerKVLoginPrepareResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
firstToken = lifetimeToken;
|
||||
}
|
||||
else if (firstToken != lifetimeToken) Interlocked.Increment(ref differentTokenCount);
|
||||
result = preparation.Task;
|
||||
alreadyClosing = closing != null;
|
||||
}
|
||||
if (!start)
|
||||
{
|
||||
if (firstToken != lifetimeToken) Debug.WriteLine("KV PrepareAsync ignored a different lifetime token; the first token still owns this context.");
|
||||
return result;
|
||||
}
|
||||
if (alreadyClosing) _ = CompleteFailureAsync(new Attempt(), PlayerKVLoginPrepareError.Cancelled);
|
||||
else _ = PrepareCoreAsync();
|
||||
return result;
|
||||
}
|
||||
|
||||
sealed class Attempt
|
||||
{
|
||||
internal AccountLoginError? AccountError;
|
||||
internal int? HttpStatus;
|
||||
internal PlayerKVGameOpenError? OpenError;
|
||||
internal PlayerKVSessionOpenError? OwnerError;
|
||||
internal PlayerKVError? KVError;
|
||||
internal PlayerKVSyncStatus? SyncStatus;
|
||||
internal string OwnerId, ExceptionType;
|
||||
}
|
||||
|
||||
async Task PrepareCoreAsync()
|
||||
{
|
||||
var evidence = new Attempt();
|
||||
var error = PlayerKVLoginPrepareError.Unexpected;
|
||||
try
|
||||
{
|
||||
registration = firstToken.Register(() => { _ = CloseAsync(); });
|
||||
if (!Validate()) error = PlayerKVLoginPrepareError.Validation;
|
||||
else if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else
|
||||
{
|
||||
session = sessionFactory() ?? throw new InvalidOperationException("Session factory returned null.");
|
||||
if (session.ApiBaseUri != api) error = PlayerKVLoginPrepareError.Validation;
|
||||
else
|
||||
{
|
||||
var auth = await session.LoginAsync(playerId, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.AccountError = auth.Error; evidence.HttpStatus = auth.StatusCode;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!auth.Success) error = PlayerKVLoginPrepareError.AuthenticationFailed;
|
||||
else
|
||||
{
|
||||
cookies = session.Cookies;
|
||||
bridge = bridgeFactory() ?? throw new InvalidOperationException("Bridge factory returned null.");
|
||||
var opened = await bridge.OpenAsync(session, cacheRoot, playerId, api, epoch,
|
||||
EpochCurrent, lifetime.Token).ConfigureAwait(false);
|
||||
evidence.OpenError = opened.Error;
|
||||
if (opened.Error == PlayerKVGameOpenError.OwnerRejected)
|
||||
{ evidence.OwnerError = opened.OwnerError; evidence.KVError = opened.TransportError; }
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!opened.Success) error = PlayerKVLoginPrepareError.OwnerRejected;
|
||||
else
|
||||
{
|
||||
handle = opened.Handle;
|
||||
evidence.OwnerId = handle.OwnerInstanceId;
|
||||
var read = handle.ReadLocalSnapshot();
|
||||
evidence.SyncStatus = handle.Status;
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else if (!read.Success) error = read.Error == PlayerKVLocalReadError.StaleOrClosing
|
||||
? PlayerKVLoginPrepareError.Stale : PlayerKVLoginPrepareError.NotReady;
|
||||
else
|
||||
{
|
||||
var success = Result(evidence, PlayerKVLoginPrepareError.None, read.Data);
|
||||
// External callback is never invoked under our monitor. Close/reentry is rechecked at publication.
|
||||
if (!EpochCurrent(epoch)) error = InactiveError();
|
||||
else lock (gate)
|
||||
{
|
||||
if (revoked == 0)
|
||||
{
|
||||
accountDescriptor = new PlayerKVAccountDescriptor(session.PlayerId,
|
||||
epoch, session.InternalId, session.NewlyCreated,
|
||||
session.CreateTimeUtc, session.ServerTimeUtc);
|
||||
Volatile.Write(ref prepared, 1);
|
||||
preparation.TrySetResult(success);
|
||||
return;
|
||||
}
|
||||
error = PlayerKVLoginPrepareError.Cancelled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
evidence.ExceptionType = ex.GetType().Name;
|
||||
error = Volatile.Read(ref revoked) != 0 ? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Unexpected;
|
||||
}
|
||||
finally { workSettled.TrySetResult(true); }
|
||||
evidence.ExceptionType = evidence.ExceptionType ?? Volatile.Read(ref predicateExceptionType);
|
||||
await CompleteFailureAsync(evidence, error).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
bool Validate()
|
||||
{
|
||||
if (epoch < 0 || externalCurrent == null || sessionFactory == null || bridgeFactory == null ||
|
||||
string.IsNullOrWhiteSpace(cacheRoot) || !Path.IsPathFullyQualified(cacheRoot) ||
|
||||
!long.TryParse(requestedId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0 ||
|
||||
api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0) return false;
|
||||
try { Path.GetFullPath(cacheRoot); }
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException) { return false; }
|
||||
playerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool EpochCurrent(long observedEpoch)
|
||||
{
|
||||
if (Volatile.Read(ref revoked) != 0 || observedEpoch != epoch) return false;
|
||||
try { return externalCurrent != null && externalCurrent(epoch) && Volatile.Read(ref revoked) == 0; }
|
||||
catch (Exception ex)
|
||||
{
|
||||
Interlocked.CompareExchange(ref predicateExceptionType, ex.GetType().Name, null);
|
||||
_ = CloseAsync();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
PlayerKVLoginPrepareError InactiveError() => Volatile.Read(ref predicateExceptionType) != null
|
||||
? PlayerKVLoginPrepareError.Unexpected : Volatile.Read(ref revoked) != 0
|
||||
? PlayerKVLoginPrepareError.Cancelled : PlayerKVLoginPrepareError.Stale;
|
||||
|
||||
PlayerKVLoginPrepareResult Result(Attempt e, PlayerKVLoginPrepareError error,
|
||||
System.Collections.Generic.IReadOnlyDictionary<string, string> data = null,
|
||||
string cleanupException = null, PlayerKVLoginPrepareError? original = null) =>
|
||||
new PlayerKVLoginPrepareResult(error, playerId, epoch, e.AccountError, e.HttpStatus,
|
||||
e.OpenError, e.OwnerError, e.KVError, e.SyncStatus, e.OwnerId, data,
|
||||
e.ExceptionType, cleanupException, original);
|
||||
|
||||
async Task CompleteFailureAsync(Attempt evidence, PlayerKVLoginPrepareError error)
|
||||
{
|
||||
try
|
||||
{
|
||||
await CloseAsync().ConfigureAwait(false);
|
||||
preparation.TrySetResult(Result(evidence, error));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
preparation.TrySetResult(Result(evidence, PlayerKVLoginPrepareError.CloseFailed,
|
||||
cleanupException: ex.GetType().Name, original: error));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Synchronously revokes admission; drains preparation and bridge before disposing the session.</summary>
|
||||
public Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
Volatile.Write(ref revoked, 1);
|
||||
completion = closing = NewSignal();
|
||||
if (preparation == null) workSettled.TrySetResult(true);
|
||||
}
|
||||
// A cancellation registration must not execute disposal of itself or user cancellation callbacks inline.
|
||||
// Observe faults without changing the shared task, even when cancellation initiates a background Close.
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(() => CloseCoreAsync(completion));
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await workSettled.Task.ConfigureAwait(false);
|
||||
PlayerKVAccountLease borrowed;
|
||||
lock (gate) borrowed = accountLease;
|
||||
if (borrowed != null) await borrowed.CloseAsync().ConfigureAwait(false);
|
||||
if (bridge != null) await bridge.CloseAsync().ConfigureAwait(false);
|
||||
// Never dispose the session on a failed bridge close. References and the faulted task stay retained.
|
||||
session?.Dispose();
|
||||
registration.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> NewSignal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
|
||||
// Kept with the context so its existing explicitly linked regression host retains all dependencies.
|
||||
internal sealed class PlayerKVAccountDescriptor
|
||||
{
|
||||
internal string PlayerId { get; }
|
||||
internal long Epoch { get; }
|
||||
internal uint InternalId { get; }
|
||||
internal bool NewlyCreated { get; }
|
||||
internal DateTime CreateTimeUtc { get; }
|
||||
internal DateTime ServerTimeUtc { get; }
|
||||
internal PlayerKVAccountDescriptor(string playerId, long epoch, uint internalId,
|
||||
bool newlyCreated, DateTime createTimeUtc, DateTime serverTimeUtc)
|
||||
{
|
||||
PlayerId = playerId; Epoch = epoch; InternalId = internalId; NewlyCreated = newlyCreated;
|
||||
CreateTimeUtc = createTimeUtc; ServerTimeUtc = serverTimeUtc;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Construction is absent from player builds; no default network handler is supplied.</summary>
|
||||
internal sealed class PlayerKVControlledLoadCapability
|
||||
{
|
||||
readonly Func<CookieContainer, HttpMessageHandler> leaseHandler;
|
||||
private PlayerKVControlledLoadCapability(Func<CookieContainer, HttpMessageHandler> leaseHandler)
|
||||
{ this.leaseHandler = leaseHandler ?? throw new ArgumentNullException(nameof(leaseHandler)); }
|
||||
|
||||
#if UNITY_EDITOR
|
||||
internal static PlayerKVControlledLoadCapability CreateForValidation(
|
||||
Func<CookieContainer, HttpMessageHandler> leaseHandler) => new PlayerKVControlledLoadCapability(leaseHandler);
|
||||
#endif
|
||||
|
||||
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
|
||||
{
|
||||
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Synthetic handler required.");
|
||||
var seen = new HashSet<HttpMessageHandler>();
|
||||
var node = handler;
|
||||
while (node != null)
|
||||
{
|
||||
// Disposing a cyclic DelegatingHandler graph recursively would overflow the stack.
|
||||
// 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")
|
||||
{
|
||||
handler.Dispose();
|
||||
throw new InvalidOperationException("Real or cyclic transport is not a validation handler.");
|
||||
}
|
||||
node = (node as DelegatingHandler)?.InnerHandler;
|
||||
}
|
||||
return handler;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class PlayerKVAccountResponse
|
||||
{
|
||||
internal bool IsCurrent { get; }
|
||||
internal int? StatusCode { get; }
|
||||
internal string Body { get; }
|
||||
internal PlayerKVAccountResponse(bool isCurrent, int? statusCode = null, string body = null)
|
||||
{ IsCurrent = isCurrent; StatusCode = isCurrent ? statusCode : null; Body = isCurrent ? body : null; }
|
||||
}
|
||||
|
||||
/// <summary>A single context-owned, synthetic-only borrower. Close drains before disposing its client.</summary>
|
||||
internal sealed class PlayerKVAccountLease
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly HttpClient client;
|
||||
readonly Func<bool> current;
|
||||
readonly Func<bool> admission;
|
||||
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
|
||||
TaskCompletionSource<bool> drained;
|
||||
TaskCompletionSource<bool> closing;
|
||||
int active;
|
||||
internal Uri ApiBaseUri { get; }
|
||||
internal long Epoch { get; }
|
||||
internal bool IsCurrent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate) if (closing != null) return false;
|
||||
return current();
|
||||
}
|
||||
}
|
||||
|
||||
internal PlayerKVAccountLease(Uri api, long epoch, CookieContainer cookies,
|
||||
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;
|
||||
client = new HttpClient(capability.CreateHandler(cookies)) { BaseAddress = api };
|
||||
}
|
||||
|
||||
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")
|
||||
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));
|
||||
if (!IsCurrent) return new PlayerKVAccountResponse(false);
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null || !admission()) return new PlayerKVAccountResponse(false);
|
||||
active++;
|
||||
}
|
||||
try
|
||||
{
|
||||
using (var linked = CancellationTokenSource.CreateLinkedTokenSource(token, lifetime.Token))
|
||||
using (var request = new HttpRequestMessage(method, route))
|
||||
{
|
||||
if (body != null) request.Content = new StringContent(body, System.Text.Encoding.UTF8, contentType);
|
||||
using (var response = await client.SendAsync(request, linked.Token).ConfigureAwait(false))
|
||||
{
|
||||
if (!IsCurrent || linked.IsCancellationRequested) return new PlayerKVAccountResponse(false);
|
||||
var text = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
|
||||
// No redirect following, even if a synthetic handler returns a Location header.
|
||||
if ((int)response.StatusCode >= 300 && (int)response.StatusCode < 400)
|
||||
throw new InvalidOperationException("Redirects are not accepted.");
|
||||
return IsCurrent && !linked.IsCancellationRequested
|
||||
? new PlayerKVAccountResponse(true, (int)response.StatusCode, text)
|
||||
: new PlayerKVAccountResponse(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!IsCurrent || token.IsCancellationRequested || lifetime.IsCancellationRequested)
|
||||
{ return new PlayerKVAccountResponse(false); }
|
||||
finally
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
active--;
|
||||
if (active == 0) drained?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (gate)
|
||||
{
|
||||
if (closing != null) return closing.Task;
|
||||
completion = closing = Signal();
|
||||
drained = Signal();
|
||||
if (active == 0) drained.TrySetResult(true);
|
||||
}
|
||||
_ = completion.Task.ContinueWith(t => { _ = t.Exception; }, CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
Exception cancellationFailure = null;
|
||||
try { lifetime.Cancel(); }
|
||||
catch (Exception ex) { cancellationFailure = ex; }
|
||||
await drained.Task.ConfigureAwait(false);
|
||||
client.Dispose();
|
||||
lifetime.Dispose();
|
||||
if (cancellationFailure != null) throw cancellationFailure;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
});
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1cc96c3a86184fd9b723896a55c6a929
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVLoginPrepareError
|
||||
{
|
||||
None, Validation, Cancelled, Stale, AuthenticationFailed, OwnerRejected, NotReady, Unexpected, CloseFailed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Historical outcome of one preparation, not permission to initialize gameplay or create a save.
|
||||
/// Check the context's IsCurrent before using a prepared snapshot. Failure/cancellation waits for
|
||||
/// actual transport and Close completion; a transport ignoring cancellation may hold it indefinitely.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVLoginPrepareResult
|
||||
{
|
||||
public bool Prepared => Error == PlayerKVLoginPrepareError.None;
|
||||
public PlayerKVLoginPrepareError Error { get; }
|
||||
public PlayerKVLoginPrepareError OriginalError { get; }
|
||||
public bool CleanupFailed => Error == PlayerKVLoginPrepareError.CloseFailed;
|
||||
public AccountLoginError? AccountError { get; }
|
||||
public PlayerKVGameOpenError? OpenError { get; }
|
||||
public PlayerKVSessionOpenError? OwnerError { get; }
|
||||
public PlayerKVError? KVError { get; }
|
||||
public PlayerKVSyncStatus? SyncStatus { get; }
|
||||
public int? HttpStatusCode { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public string OwnerInstanceId { get; }
|
||||
public string ExceptionType { get; }
|
||||
public string CleanupExceptionType { get; }
|
||||
public IReadOnlyDictionary<string, string> Data { get; }
|
||||
public bool IsEmpty => Prepared && Data.Count == 0;
|
||||
|
||||
internal PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError error, string playerId, long epoch,
|
||||
AccountLoginError? accountError = null, int? httpStatus = null,
|
||||
PlayerKVGameOpenError? openError = null, PlayerKVSessionOpenError? ownerError = null,
|
||||
PlayerKVError? kvError = null, PlayerKVSyncStatus? syncStatus = null,
|
||||
string ownerId = null, IReadOnlyDictionary<string, string> data = null,
|
||||
string exceptionType = null, string cleanupExceptionType = null,
|
||||
PlayerKVLoginPrepareError? originalError = null)
|
||||
{
|
||||
Error = error; OriginalError = originalError ?? error; PlayerId = playerId; Epoch = epoch;
|
||||
AccountError = accountError; HttpStatusCode = httpStatus; OpenError = openError;
|
||||
OwnerError = ownerError; KVError = kvError; SyncStatus = syncStatus; OwnerInstanceId = ownerId;
|
||||
ExceptionType = exceptionType; CleanupExceptionType = cleanupExceptionType;
|
||||
if (!Prepared) return;
|
||||
if (data == null) throw new ArgumentNullException(nameof(data));
|
||||
var copy = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in data) copy.Add(pair.Key, pair.Value);
|
||||
Data = new ReadOnlyDictionary<string, string>(copy);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d2c0df39903a45f780cc6751d1774c51
|
||||
31
Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs
Normal file
31
Client/Assets/Scripts/Services/PlayerData/PlayerKVModels.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVError
|
||||
{
|
||||
None, Validation, Unauthorized, Http, Network, Timeout, Cancelled, Protocol
|
||||
}
|
||||
|
||||
public sealed class PlayerKVResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVError.None;
|
||||
public PlayerKVError Error { get; }
|
||||
public int? StatusCode { get; }
|
||||
// Only a successful Get has Data. Failure must never look like an empty save.
|
||||
public IReadOnlyDictionary<string, string> Data { get; }
|
||||
// A failed write that entered the transport may already have reached the server.
|
||||
// This is conservative, including HTTP failures. Do not blindly replay the write.
|
||||
public bool MayHaveCommitted { get; }
|
||||
|
||||
internal PlayerKVResult(PlayerKVError error, int? statusCode = null,
|
||||
Dictionary<string, string> data = null, bool mayHaveCommitted = false)
|
||||
{
|
||||
Error = error;
|
||||
StatusCode = statusCode;
|
||||
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
|
||||
MayHaveCommitted = mayHaveCommitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9401473b45424dbf94ddece933db1c86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,126 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static class PlayerKVSaveRouteEditorValidationHost
|
||||
{
|
||||
static int running;
|
||||
// Retain a failed cleanup's owner for diagnosis; never silently restart over it.
|
||||
static object failedLifetime;
|
||||
internal static async Task<string> RunAsync(string caseId)
|
||||
{
|
||||
if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case.");
|
||||
if (Interlocked.CompareExchange(ref running, 1, 0) != 0 || failedLifetime != null)
|
||||
throw new InvalidOperationException("Validation already running or cleanup failed.");
|
||||
var run = new Run();
|
||||
try { return await run.Execute(); }
|
||||
finally
|
||||
{
|
||||
try { await run.Close(); }
|
||||
catch { failedLifetime = run; throw; }
|
||||
finally { Interlocked.Exchange(ref running, 0); }
|
||||
}
|
||||
}
|
||||
|
||||
sealed class Run
|
||||
{
|
||||
readonly Uri api = new Uri("http://kv-route.invalid/");
|
||||
readonly List<S101AccountSession> sessions = new List<S101AccountSession>();
|
||||
readonly Dictionary<string, Dictionary<string, string>> remote = new Dictionary<string, Dictionary<string, string>>
|
||||
{ ["101"] = new Dictionary<string, string> { ["k"] = "A", ["empty"] = "" }, ["102"] = new Dictionary<string, string> { ["k"] = "B" } };
|
||||
readonly string cache = Path.GetFullPath(Path.Combine(UnityEngine.Application.dataPath,
|
||||
"../../CodexOutput/upserver/KVSaveRouteTests/unity-runs", DateTime.UtcNow.ToString("yyyyMMddHHmmss") + "_" + Guid.NewGuid().ToString("N")));
|
||||
PlayerKVGameSessionBridge bridge;
|
||||
PlayerKVGameLoadGuard guard;
|
||||
int assertions, auth, gets, writes;
|
||||
long epoch = 1;
|
||||
void Check(bool value) { assertions++; if (!value) throw new InvalidOperationException("Fixed route assertion " + assertions); }
|
||||
static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{ Content = new StringContent(JsonConvert.SerializeObject(value)) };
|
||||
sealed class SyntheticHandler : HttpMessageHandler
|
||||
{
|
||||
readonly Func<HttpRequestMessage, Task<HttpResponseMessage>> send;
|
||||
internal SyntheticHandler(Func<HttpRequestMessage, Task<HttpResponseMessage>> send) { this.send = send; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken token) => send(request);
|
||||
}
|
||||
HttpMessageHandler KV(CookieContainer jar)
|
||||
{
|
||||
var id = jar.GetCookies(api)["sid"].Value;
|
||||
return new SyntheticHandler(async request =>
|
||||
{
|
||||
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.Port == 80);
|
||||
var path = request.RequestUri.AbsolutePath;
|
||||
Check(path == "/playerdata/Get" || path == "/playerdata/Set");
|
||||
var body = JObject.Parse(await request.Content.ReadAsStringAsync());
|
||||
if (path == "/playerdata/Get") { gets++; return Json(new { data = remote[id] }); }
|
||||
writes++;
|
||||
foreach (var p in ((JObject)body["data"]).Properties()) remote[id][p.Name] = (string)p.Value;
|
||||
return Json(new { });
|
||||
});
|
||||
}
|
||||
async Task<PlayerKVGameSessionHandle> Open(string id)
|
||||
{
|
||||
var session = new S101AccountSession(api, jar => new SyntheticHandler(request =>
|
||||
{
|
||||
Check(request.RequestUri.Host == "kv-route.invalid" && request.RequestUri.AbsolutePath == "/account/Login");
|
||||
auth++; jar.Add(api, new Cookie("sid", id));
|
||||
return Task.FromResult(Json(new { state = 0, internalId = int.Parse(id), newCreate = false,
|
||||
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" }));
|
||||
}));
|
||||
sessions.Add(session);
|
||||
Check((await session.LoginAsync(id)).Success);
|
||||
var opened = await bridge.OpenAsync(session, cache, id, api, epoch, e => e == epoch);
|
||||
Check(opened.Success); return opened.Handle;
|
||||
}
|
||||
internal async Task<string> Execute()
|
||||
{
|
||||
// Never disturb a guard held by another validation or a controlled login.
|
||||
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);
|
||||
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(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(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();
|
||||
return JsonConvert.SerializeObject(new { success = true, assertions, auth, gets, writes, formalRequests = 0, restored = true, cache });
|
||||
}
|
||||
internal async Task Close()
|
||||
{
|
||||
if (bridge != null) await bridge.CloseAsync();
|
||||
foreach (var session in sessions) session.Dispose(); sessions.Clear();
|
||||
guard?.Dispose(); guard = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 860b04f8a2a64eb8acf77e96695f6c54
|
||||
1168
Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs
Normal file
1168
Client/Assets/Scripts/Services/PlayerData/PlayerKVSaveSchema.cs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c49515cb90840a189772d23ad69fd2b
|
||||
@@ -0,0 +1,341 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVSessionOpenError
|
||||
{
|
||||
None, Validation, Unauthenticated, Busy, RecoveryRequired, StorageError,
|
||||
ReadFailed, Unauthorized, Stale, Cancelled
|
||||
}
|
||||
|
||||
public sealed class PlayerKVSessionOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVSessionOpenError.None;
|
||||
public PlayerKVSessionOpenError Error { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSessionOwner Owner { get; }
|
||||
internal PlayerKVSessionOpenResult(PlayerKVSessionOpenError error,
|
||||
PlayerKVError transportError = PlayerKVError.None, PlayerKVSessionOwner owner = null)
|
||||
{ Error = error; TransportError = transportError; Owner = owner; }
|
||||
}
|
||||
|
||||
/// <summary>Owns one immutable account/epoch/cache/transport tuple.</summary>
|
||||
public sealed class PlayerKVSessionOwner : IDisposable
|
||||
{
|
||||
sealed class OperationLease : IDisposable
|
||||
{
|
||||
PlayerKVSessionOwner owner;
|
||||
internal OperationLease(PlayerKVSessionOwner owner) { this.owner = owner; }
|
||||
public void Dispose() { Interlocked.Exchange(ref owner, null)?.ExitOperation(); }
|
||||
}
|
||||
|
||||
readonly object admission = new object();
|
||||
readonly S101AccountSession session;
|
||||
readonly CookieContainer cookies;
|
||||
readonly CancellationTokenSource lifetime;
|
||||
readonly Func<long, bool> isCurrent;
|
||||
readonly PlayerKVSyncState state;
|
||||
readonly PlayerKVSyncCoordinator coordinator;
|
||||
readonly TaskCompletionSource<bool> drained =
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
readonly string ownerId = Guid.NewGuid().ToString("N");
|
||||
int activeOperations;
|
||||
bool closing, closed;
|
||||
TaskCompletionSource<bool> closeCompletion;
|
||||
|
||||
public string OwnerInstanceId => ownerId;
|
||||
public Uri ApiBaseUri { get; }
|
||||
public string PlayerId { get; }
|
||||
public long Epoch { get; }
|
||||
public PlayerKVSyncStatus Status => state.Status;
|
||||
|
||||
PlayerKVSessionOwner(S101AccountSession session, CookieContainer cookies, Uri api, string playerId,
|
||||
long epoch, Func<long, bool> isCurrent, CancellationTokenSource lifetime,
|
||||
PlayerKVSyncState state, HttpClient http)
|
||||
{
|
||||
this.session = session; this.cookies = cookies; ApiBaseUri = api; PlayerId = playerId;
|
||||
Epoch = epoch; this.isCurrent = isCurrent; this.lifetime = lifetime; this.state = state;
|
||||
coordinator = new PlayerKVSyncCoordinator(state, new PlayerKVClient(http, api), http,
|
||||
ownerId, epoch, IsCapturedOwnerCurrent, lifetime.Token);
|
||||
}
|
||||
|
||||
public static Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot,
|
||||
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
|
||||
CancellationToken lifetimeToken = default) =>
|
||||
CreateAsync(session, cacheRoot, expectedPlayerId, expectedApiBaseUri, epoch, isCurrent,
|
||||
lifetimeToken, jar => new HttpClientHandler
|
||||
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false });
|
||||
|
||||
internal static async Task<PlayerKVSessionOpenResult> CreateAsync(S101AccountSession session, string cacheRoot,
|
||||
string expectedPlayerId, Uri expectedApiBaseUri, long epoch, Func<long, bool> isCurrent,
|
||||
CancellationToken lifetimeToken, Func<CookieContainer, HttpMessageHandler> handlerFactory,
|
||||
TimeSpan? transportTimeout = null)
|
||||
{
|
||||
if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 ||
|
||||
!long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation);
|
||||
var playerId = numericId.ToString(CultureInfo.InvariantCulture);
|
||||
Uri expectedApi;
|
||||
try { expectedApi = NormalizeApi(expectedApiBaseUri); }
|
||||
catch (ArgumentException) { return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation); }
|
||||
if (lifetimeToken.IsCancellationRequested)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
if (!session.IsAuthenticated || session.Cookies == null)
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Unauthenticated);
|
||||
var capturedCookies = session.Cookies;
|
||||
var capturedApi = NormalizeApi(session.ApiBaseUri);
|
||||
if (session.PlayerId != playerId || capturedApi != expectedApi || !isCurrent(epoch))
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Stale);
|
||||
|
||||
var opened = PlayerKVSyncState.Open(cacheRoot, capturedApi, playerId);
|
||||
if (!opened.Success) return new PlayerKVSessionOpenResult(MapOpen(opened.Error));
|
||||
HttpClient http = null;
|
||||
CancellationTokenSource lifetime = null;
|
||||
PlayerKVSessionOwner owner = null;
|
||||
try
|
||||
{
|
||||
var handler = handlerFactory(capturedCookies);
|
||||
if (handler == null) throw new ArgumentException("Transport factory returned null.");
|
||||
http = new HttpClient(handler) { Timeout = transportTimeout ?? TimeSpan.FromSeconds(15) };
|
||||
lifetime = CancellationTokenSource.CreateLinkedTokenSource(lifetimeToken);
|
||||
owner = new PlayerKVSessionOwner(session, capturedCookies, capturedApi, playerId, epoch,
|
||||
isCurrent, lifetime, opened.State, http);
|
||||
http = null; lifetime = null;
|
||||
PlayerKVResult read;
|
||||
using (var lease = owner.TryEnter())
|
||||
{
|
||||
if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled);
|
||||
read = await owner.coordinator.InitializeAsync();
|
||||
}
|
||||
if (!read.Success)
|
||||
return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized
|
||||
? PlayerKVSessionOpenError.Unauthorized
|
||||
: read.Error == PlayerKVError.Cancelled ? PlayerKVSessionOpenError.Cancelled
|
||||
: PlayerKVSessionOpenError.ReadFailed, read.Error);
|
||||
if (!owner.IsCapturedOwnerCurrent())
|
||||
return await owner.FailCreate(PlayerKVSessionOpenError.Stale, PlayerKVError.Cancelled);
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.None, owner: owner);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
opened.State.Dispose();
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Cancelled, PlayerKVError.Cancelled);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
if (owner != null) return await owner.FailCreate(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol);
|
||||
http?.Dispose(); lifetime?.Dispose(); opened.State.Dispose();
|
||||
return new PlayerKVSessionOpenResult(PlayerKVSessionOpenError.Validation, PlayerKVError.Protocol);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the existing state under an operation lease; does not open another cache.</summary>
|
||||
public PlayerKVLocalReadResult ReadLocalSnapshot()
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
{
|
||||
if (lease == null) return ReadFailure(PlayerKVLocalReadError.StaleOrClosing);
|
||||
try
|
||||
{
|
||||
var data = state.ReadLocal();
|
||||
lock (admission)
|
||||
{
|
||||
if (!IsCapturedOwnerCurrent() || closing || closed)
|
||||
return ReadFailure(PlayerKVLocalReadError.StaleOrClosing);
|
||||
return new PlayerKVLocalReadResult(PlayerKVLocalReadError.None,
|
||||
ownerId, PlayerId, Epoch, data);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
lock (admission)
|
||||
return ReadFailure(!IsCapturedOwnerCurrent() || closing || closed
|
||||
? PlayerKVLocalReadError.StaleOrClosing : PlayerKVLocalReadError.NotReady);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) =>
|
||||
new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch);
|
||||
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore);
|
||||
}
|
||||
|
||||
public PlayerKVEditResult Delete(string key)
|
||||
{
|
||||
using (var lease = TryEnter())
|
||||
return lease == null ? ClosedEdit() : coordinator.Delete(key);
|
||||
}
|
||||
|
||||
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default)
|
||||
{
|
||||
var lease = TryEnter();
|
||||
if (lease == null) return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0,
|
||||
PlayerKVError.Cancelled, state.Status));
|
||||
return WaitForFlushAsync(RunFlushAsync(lease), waitToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs a complete recovery Get on the same serialized channel and fixed owner transport.</summary>
|
||||
public Task<PlayerKVResult> RefreshAsync(CancellationToken waitToken = default)
|
||||
{
|
||||
var lease = TryEnter();
|
||||
if (lease == null) return Task.FromResult(new PlayerKVResult(PlayerKVError.Cancelled));
|
||||
var shared = RunRefreshAsync(lease);
|
||||
return WaitForRefreshAsync(shared, waitToken);
|
||||
}
|
||||
|
||||
public Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket,
|
||||
CancellationToken cancellationToken = default) => coordinator.WaitForTicketAsync(ticket, cancellationToken);
|
||||
|
||||
// Watermark capture must happen inside the lease scope: a failure there still
|
||||
// returns the operation lease, or CloseAsync would wait for this operation forever.
|
||||
async Task<PlayerKVFlushResult> RunFlushAsync(OperationLease lease)
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try
|
||||
{
|
||||
var watermark = coordinator.CaptureWatermark(out var targetSequences);
|
||||
return await coordinator.FlushThroughAsync(watermark, targetSequences);
|
||||
}
|
||||
catch (Exception) { return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Protocol, state.Status); }
|
||||
}
|
||||
}
|
||||
|
||||
async Task<PlayerKVResult> RunRefreshAsync(OperationLease lease)
|
||||
{
|
||||
using (lease)
|
||||
{
|
||||
try { return await coordinator.InitializeAsync(); }
|
||||
catch (OperationCanceledException) { return new PlayerKVResult(PlayerKVError.Cancelled); }
|
||||
catch (ObjectDisposedException) { return new PlayerKVResult(PlayerKVError.Cancelled); }
|
||||
catch (HttpRequestException) { return new PlayerKVResult(PlayerKVError.Network); }
|
||||
catch (IOException) { return new PlayerKVResult(PlayerKVError.Network); }
|
||||
catch (Exception) { return new PlayerKVResult(PlayerKVError.Protocol); }
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<PlayerKVResult> WaitForRefreshAsync(Task<PlayerKVResult> shared, CancellationToken token)
|
||||
{
|
||||
if (!token.CanBeCanceled) return await shared;
|
||||
if (token.IsCancellationRequested) return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared;
|
||||
}
|
||||
return new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
async Task<PlayerKVFlushResult> WaitForFlushAsync(Task<PlayerKVFlushResult> shared, CancellationToken token)
|
||||
{
|
||||
if (!token.CanBeCanceled) return await shared;
|
||||
if (token.IsCancellationRequested) return new PlayerKVFlushResult(0, 0, 1, 0,
|
||||
PlayerKVError.Cancelled, state.Status);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(shared, cancelled.Task) == shared) return await shared;
|
||||
}
|
||||
return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status);
|
||||
}
|
||||
|
||||
OperationLease TryEnter()
|
||||
{
|
||||
lock (admission)
|
||||
{
|
||||
if (closing || closed || !IsCapturedOwnerCurrent()) return null;
|
||||
activeOperations++;
|
||||
return new OperationLease(this);
|
||||
}
|
||||
}
|
||||
|
||||
void ExitOperation()
|
||||
{
|
||||
lock (admission)
|
||||
{
|
||||
activeOperations--;
|
||||
if (closing && activeOperations == 0) drained.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
|
||||
public Task CloseAsync()
|
||||
{
|
||||
TaskCompletionSource<bool> completion;
|
||||
lock (admission)
|
||||
{
|
||||
if (closeCompletion != null) return closeCompletion.Task;
|
||||
closing = true;
|
||||
completion = closeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
if (activeOperations == 0) drained.TrySetResult(true);
|
||||
}
|
||||
_ = CloseCoreAsync(completion);
|
||||
return completion.Task;
|
||||
}
|
||||
|
||||
async Task CloseCoreAsync(TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
lifetime.Cancel();
|
||||
coordinator.CloseTickets();
|
||||
await drained.Task;
|
||||
coordinator.Dispose();
|
||||
state.Dispose();
|
||||
lifetime.Dispose();
|
||||
lock (admission) closed = true;
|
||||
completion.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex) { completion.TrySetException(ex); }
|
||||
}
|
||||
|
||||
async Task<PlayerKVSessionOpenResult> FailCreate(PlayerKVSessionOpenError error,
|
||||
PlayerKVError transport = PlayerKVError.None)
|
||||
{
|
||||
await CloseAsync();
|
||||
return new PlayerKVSessionOpenResult(error, transport);
|
||||
}
|
||||
|
||||
bool IsCapturedOwnerCurrent()
|
||||
{
|
||||
if (lifetime.IsCancellationRequested || !isCurrent(Epoch)) return false;
|
||||
return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) &&
|
||||
session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri;
|
||||
}
|
||||
|
||||
static Uri NormalizeApi(Uri api)
|
||||
{
|
||||
if (api == null || !api.IsAbsoluteUri || (api.Scheme != "http" && api.Scheme != "https") ||
|
||||
api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0)
|
||||
throw new ArgumentException("Invalid API base URI.");
|
||||
return new Uri(api.AbsoluteUri.TrimEnd('/') + "/");
|
||||
}
|
||||
|
||||
static PlayerKVSessionOpenError MapOpen(PlayerKVOpenError error)
|
||||
{
|
||||
if (error == PlayerKVOpenError.Busy) return PlayerKVSessionOpenError.Busy;
|
||||
if (error == PlayerKVOpenError.RecoveryRequired) return PlayerKVSessionOpenError.RecoveryRequired;
|
||||
if (error == PlayerKVOpenError.StorageError) return PlayerKVSessionOpenError.StorageError;
|
||||
return PlayerKVSessionOpenError.Validation;
|
||||
}
|
||||
|
||||
PlayerKVEditResult ClosedEdit() => new PlayerKVEditResult(
|
||||
new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: state.Inspect().Revision));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ = CloseAsync().ContinueWith(task => { var observed = task.Exception; },
|
||||
CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e46e2622d9b94d61aeb796d53cea5cdc
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,92 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal enum PlayerKVShutdownStatus { Ready, Pending, Failed, Cancelled, Input }
|
||||
|
||||
/// <summary>Process-lifetime barrier. Faulted and unfinished work cannot be reset by replacing a scene/container.</summary>
|
||||
internal sealed class PlayerKVShutdownRegistry
|
||||
{
|
||||
internal static PlayerKVShutdownRegistry Process { get; } = new PlayerKVShutdownRegistry();
|
||||
readonly object gate = new object();
|
||||
readonly Dictionary<Task, PlayerKVLoginContext> pending = new Dictionary<Task, PlayerKVLoginContext>();
|
||||
TaskCompletionSource<bool> changed = Signal();
|
||||
|
||||
internal void Register(Task task, PlayerKVLoginContext owner = null)
|
||||
{
|
||||
if (task == null) throw new ArgumentNullException(nameof(task));
|
||||
lock (gate)
|
||||
{
|
||||
if (!pending.ContainsKey(task) || owner != null) pending[task] = owner;
|
||||
NotifyChanged();
|
||||
}
|
||||
_ = task.ContinueWith(t =>
|
||||
{
|
||||
if (t.IsFaulted) { _ = t.Exception; }
|
||||
lock (gate)
|
||||
{
|
||||
if (t.Status == TaskStatus.RanToCompletion) pending.Remove(t);
|
||||
NotifyChanged();
|
||||
}
|
||||
// Cancelled work does not prove resource cleanup; retain it as a failure barrier.
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
}
|
||||
|
||||
internal PlayerKVShutdownStatus Status
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (pending.Keys.Any(t => t.IsFaulted || t.IsCanceled)) return PlayerKVShutdownStatus.Failed;
|
||||
return pending.Keys.Any(t => !t.IsCompleted) ? PlayerKVShutdownStatus.Pending : PlayerKVShutdownStatus.Ready;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Injection is an observation clock only, never a force-close or permission to reopen.
|
||||
internal async Task<PlayerKVShutdownStatus> ObserveAsync(int timeoutMs, CancellationToken token = default,
|
||||
Func<int, CancellationToken, Task> delay = null)
|
||||
{
|
||||
if (timeoutMs < 1000 || timeoutMs > 15000) return PlayerKVShutdownStatus.Input;
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
using (var clock = CancellationTokenSource.CreateLinkedTokenSource(token))
|
||||
{
|
||||
var deadline = (delay ?? Task.Delay)(timeoutMs, clock.Token);
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
Task notification;
|
||||
lock (gate)
|
||||
{
|
||||
var status = Status;
|
||||
if (status != PlayerKVShutdownStatus.Pending) return status;
|
||||
notification = changed.Task;
|
||||
}
|
||||
if (await Task.WhenAny(notification, deadline).ConfigureAwait(false) == deadline)
|
||||
{
|
||||
if (token.IsCancellationRequested) return PlayerKVShutdownStatus.Cancelled;
|
||||
return Status;
|
||||
}
|
||||
// Recheck the live collection; another Root may have registered work during the await.
|
||||
}
|
||||
}
|
||||
finally { clock.Cancel(); }
|
||||
}
|
||||
}
|
||||
|
||||
void NotifyChanged()
|
||||
{
|
||||
var previous = changed; changed = Signal(); previous.TrySetResult(true);
|
||||
}
|
||||
|
||||
static TaskCompletionSource<bool> Signal() =>
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c524f69ad00844b4a1d0456b4d3c43ae
|
||||
@@ -0,0 +1,339 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVTicketStatus { Pending, Confirmed, Superseded, Blocked, Unknown, Cancelled }
|
||||
|
||||
public sealed class PlayerKVSyncTicket
|
||||
{
|
||||
public string OwnerInstanceId { get; }
|
||||
public long Epoch { get; }
|
||||
public string Key { get; }
|
||||
public long Sequence { get; }
|
||||
internal PlayerKVSyncTicket(string ownerId, long epoch, string key, long sequence)
|
||||
{ OwnerInstanceId = ownerId; Epoch = epoch; Key = key; Sequence = sequence; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVTicketResult
|
||||
{
|
||||
public PlayerKVTicketStatus Status { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSyncStatus CacheStatus { get; }
|
||||
internal PlayerKVTicketResult(PlayerKVTicketStatus status, PlayerKVError transportError, PlayerKVSyncStatus cacheStatus)
|
||||
{ Status = status; TransportError = transportError; CacheStatus = cacheStatus; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVEditResult
|
||||
{
|
||||
public bool Accepted => StateResult != null && StateResult.Success;
|
||||
public PlayerKVStateResult StateResult { get; }
|
||||
public PlayerKVSyncTicket Ticket { get; }
|
||||
internal PlayerKVEditResult(PlayerKVStateResult result, PlayerKVSyncTicket ticket = null)
|
||||
{ StateResult = result; Ticket = ticket; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVFlushResult
|
||||
{
|
||||
public int Confirmed { get; }
|
||||
public int Superseded { get; }
|
||||
public int Blocked { get; }
|
||||
public int Unknown { get; }
|
||||
public PlayerKVError TransportError { get; }
|
||||
public PlayerKVSyncStatus CacheStatus { get; }
|
||||
internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown,
|
||||
PlayerKVError transportError, PlayerKVSyncStatus cacheStatus)
|
||||
{
|
||||
Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown;
|
||||
TransportError = transportError; CacheStatus = cacheStatus;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One fixed owner's serialized Get/Set/Delete channel. No timers or automatic retries.</summary>
|
||||
public sealed class PlayerKVSyncCoordinator : IDisposable
|
||||
{
|
||||
sealed class TicketRecord
|
||||
{
|
||||
internal readonly PlayerKVSyncTicket Ticket;
|
||||
internal readonly TaskCompletionSource<PlayerKVTicketResult> Completion =
|
||||
new TaskCompletionSource<PlayerKVTicketResult>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal bool Dispatched, ClaimedByFlush;
|
||||
internal TicketRecord(PlayerKVSyncTicket ticket) { Ticket = ticket; }
|
||||
}
|
||||
|
||||
readonly object ticketGate = new object();
|
||||
readonly Dictionary<long, TicketRecord> tickets = new Dictionary<long, TicketRecord>();
|
||||
readonly SemaphoreSlim dispatch = new SemaphoreSlim(1, 1);
|
||||
readonly PlayerKVSyncState state;
|
||||
readonly PlayerKVClient client;
|
||||
readonly System.Net.Http.HttpClient http;
|
||||
readonly string ownerId;
|
||||
readonly long epoch;
|
||||
readonly Func<bool> isOwnerCurrent;
|
||||
readonly CancellationToken ownerToken;
|
||||
bool disposed;
|
||||
|
||||
internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client,
|
||||
System.Net.Http.HttpClient http, string ownerId, long epoch, Func<bool> isOwnerCurrent,
|
||||
CancellationToken ownerToken)
|
||||
{
|
||||
this.state = state; this.client = client; this.http = http; this.ownerId = ownerId;
|
||||
this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken;
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVResult> InitializeAsync()
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
try
|
||||
{
|
||||
if (!Current()) return CancelledResult();
|
||||
var result = await client.GetAsync(null, ownerToken);
|
||||
if (!Current()) return CancelledResult();
|
||||
if (result.Success)
|
||||
{
|
||||
var reconciled = state.Reconcile(result.Data);
|
||||
return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol);
|
||||
}
|
||||
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
|
||||
return result;
|
||||
}
|
||||
finally { dispatch.Release(); }
|
||||
}
|
||||
|
||||
internal PlayerKVEditResult Update(string key, string value, bool remoteStore)
|
||||
{
|
||||
var result = state.Update(key, value, remoteStore);
|
||||
if (!result.Success || !remoteStore) return new PlayerKVEditResult(result);
|
||||
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
|
||||
lock (ticketGate)
|
||||
{
|
||||
SupersedeUnsentForKey(key, result.AttemptSequence);
|
||||
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
|
||||
}
|
||||
return new PlayerKVEditResult(result, ticket);
|
||||
}
|
||||
|
||||
internal PlayerKVEditResult Delete(string key)
|
||||
{
|
||||
var result = state.Delete(key);
|
||||
if (!result.Success) return new PlayerKVEditResult(result);
|
||||
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
|
||||
lock (ticketGate)
|
||||
{
|
||||
SupersedeUnsentForKey(key, result.AttemptSequence);
|
||||
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
|
||||
}
|
||||
return new PlayerKVEditResult(result, ticket);
|
||||
}
|
||||
|
||||
internal long CaptureWatermark(out long[] targetSequences)
|
||||
{
|
||||
var next = state.Inspect().NextSequence;
|
||||
var watermark = next <= 1 ? 0 : next - 1;
|
||||
lock (ticketGate)
|
||||
{
|
||||
targetSequences = tickets.Values.Where(record => record.Ticket.Sequence <= watermark && !record.ClaimedByFlush)
|
||||
.Select(record => { record.ClaimedByFlush = true; return record.Ticket.Sequence; }).ToArray();
|
||||
}
|
||||
return watermark;
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences)
|
||||
{
|
||||
PlayerKVError finalError = PlayerKVError.None;
|
||||
var targetSet = new HashSet<long>(targetSequences ?? Array.Empty<long>());
|
||||
var recoveredConfirmed = 0;
|
||||
var recoveredBlocked = 0;
|
||||
var recoveredUnknown = 0;
|
||||
var acquired = false;
|
||||
try
|
||||
{
|
||||
await dispatch.WaitAsync(ownerToken);
|
||||
acquired = true;
|
||||
while (Current())
|
||||
{
|
||||
MarkSuperseded(watermark);
|
||||
var begin = state.BeginBatch(watermark, out var batch);
|
||||
if (!begin.Success)
|
||||
{
|
||||
recoveredBlocked += state.Inspect().Pending.Values.Count(item =>
|
||||
item.Sequence <= watermark && !targetSet.Contains(item.Sequence));
|
||||
MarkEligibleBlocked(watermark, PlayerKVError.None);
|
||||
break;
|
||||
}
|
||||
if (batch == null) break;
|
||||
MarkDispatched(batch);
|
||||
PlayerKVResult result;
|
||||
if (batch.IsDelete)
|
||||
result = await client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
|
||||
else
|
||||
result = await client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value,
|
||||
StringComparer.Ordinal), ownerToken);
|
||||
|
||||
if (!Current() && result.Success)
|
||||
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
var confirmed = state.ConfirmBatch(batch.Id);
|
||||
if (!confirmed.Success)
|
||||
{
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Unknown, PlayerKVError.None);
|
||||
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
finalError = PlayerKVError.None;
|
||||
break;
|
||||
}
|
||||
CompleteBatch(batch, PlayerKVTicketStatus.Confirmed, PlayerKVError.None);
|
||||
recoveredConfirmed += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
continue;
|
||||
}
|
||||
|
||||
finalError = result.Error;
|
||||
var failed = state.FailBatch(batch.Id, result.MayHaveCommitted,
|
||||
result.Error == PlayerKVError.Unauthorized);
|
||||
var status = result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
|
||||
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked;
|
||||
CompleteBatch(batch, status, result.Error);
|
||||
if (status == PlayerKVTicketStatus.Unknown)
|
||||
recoveredUnknown += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
else
|
||||
recoveredBlocked += batch.Items.Count(item => !targetSet.Contains(item.Sequence));
|
||||
if (!failed.Success && status != PlayerKVTicketStatus.Unknown)
|
||||
MarkEligibleBlocked(watermark, result.Error);
|
||||
break;
|
||||
}
|
||||
if (!Current())
|
||||
{
|
||||
if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
finalError = PlayerKVError.Cancelled;
|
||||
MarkEligibleOnClose(watermark);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
finalError = PlayerKVError.Protocol;
|
||||
MarkEligibleBlocked(watermark, finalError);
|
||||
}
|
||||
finally { if (acquired) dispatch.Release(); }
|
||||
return Summary(targetSequences ?? Array.Empty<long>(), finalError,
|
||||
recoveredConfirmed, recoveredBlocked, recoveredUnknown);
|
||||
}
|
||||
|
||||
internal async Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token)
|
||||
{
|
||||
if (ticket == null || ticket.OwnerInstanceId != ownerId || ticket.Epoch != epoch)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status);
|
||||
TicketRecord record;
|
||||
lock (ticketGate)
|
||||
if (!tickets.TryGetValue(ticket.Sequence, out record) || record.Ticket.Key != ticket.Key)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, state.Status);
|
||||
if (!token.CanBeCanceled) return await record.Completion.Task;
|
||||
if (token.IsCancellationRequested)
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status);
|
||||
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using (token.Register(() => cancelled.TrySetResult(true)))
|
||||
{
|
||||
if (await Task.WhenAny(record.Completion.Task, cancelled.Task) == record.Completion.Task)
|
||||
return await record.Completion.Task;
|
||||
}
|
||||
return new PlayerKVTicketResult(PlayerKVTicketStatus.Cancelled, PlayerKVError.Cancelled, state.Status);
|
||||
}
|
||||
|
||||
internal void CloseTickets()
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (!record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled,
|
||||
PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
void MarkDispatched(PlayerKVBatch batch)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record)) record.Dispatched = true;
|
||||
}
|
||||
|
||||
void CompleteBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var item in batch.Items)
|
||||
if (tickets.TryGetValue(item.Sequence, out var record)) Complete(record, status, error);
|
||||
}
|
||||
|
||||
void SupersedeUnsentForKey(string key, long newerSequence)
|
||||
{
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Key == key && record.Ticket.Sequence < newerSequence && !record.Dispatched && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
|
||||
}
|
||||
|
||||
void MarkSuperseded(long watermark)
|
||||
{
|
||||
var snapshot = state.Inspect();
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Dispatched && !record.Completion.Task.IsCompleted &&
|
||||
snapshot.Pending.TryGetValue(record.Ticket.Key, out var latest) && latest.Sequence > record.Ticket.Sequence)
|
||||
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
|
||||
}
|
||||
|
||||
void MarkEligibleBlocked(long watermark, PlayerKVError error)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error);
|
||||
}
|
||||
|
||||
void MarkEligibleOnClose(long watermark)
|
||||
{
|
||||
lock (ticketGate)
|
||||
foreach (var record in tickets.Values)
|
||||
if (record.Ticket.Sequence <= watermark && !record.Completion.Task.IsCompleted)
|
||||
Complete(record, record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled,
|
||||
PlayerKVError.Cancelled);
|
||||
}
|
||||
|
||||
void Complete(TicketRecord record, PlayerKVTicketStatus status, PlayerKVError error) =>
|
||||
record.Completion.TrySetResult(new PlayerKVTicketResult(status, error, state.Status));
|
||||
|
||||
PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
|
||||
int recoveredConfirmed, int recoveredBlocked, int recoveredUnknown)
|
||||
{
|
||||
var counts = new Dictionary<PlayerKVTicketStatus, int>();
|
||||
lock (ticketGate)
|
||||
foreach (var sequence in targetSequences)
|
||||
{
|
||||
if (!tickets.TryGetValue(sequence, out var record)) continue;
|
||||
var status = record.Completion.Task.IsCompleted
|
||||
? record.Completion.Task.GetAwaiter().GetResult().Status : PlayerKVTicketStatus.Pending;
|
||||
counts[status] = counts.TryGetValue(status, out var count) ? count + 1 : 1;
|
||||
}
|
||||
Func<PlayerKVTicketStatus, int> countOf = status => counts.TryGetValue(status, out var count) ? count : 0;
|
||||
return new PlayerKVFlushResult(countOf(PlayerKVTicketStatus.Confirmed) + recoveredConfirmed,
|
||||
countOf(PlayerKVTicketStatus.Superseded),
|
||||
countOf(PlayerKVTicketStatus.Blocked) + countOf(PlayerKVTicketStatus.Cancelled) + recoveredBlocked,
|
||||
countOf(PlayerKVTicketStatus.Unknown) + recoveredUnknown, error, state.Status);
|
||||
}
|
||||
|
||||
bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent();
|
||||
static PlayerKVResult CancelledResult() => new PlayerKVResult(PlayerKVError.Cancelled);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
http.Dispose();
|
||||
dispatch.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ff223eaf9574c4894c4acbb6b06a387
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
249
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs
Normal file
249
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncModels.cs
Normal file
@@ -0,0 +1,249 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
public enum PlayerKVSyncStatus { NotLoaded, Ready, ReadFailed, Conflict, UnknownOutcome, Unauthorized, StorageError, Closed }
|
||||
public enum PlayerKVOpenError { None, Validation, Busy, StorageError, RecoveryRequired }
|
||||
public enum PlayerKVChangeError { None, Validation, Blocked, StorageError }
|
||||
|
||||
public sealed class PlayerKVStateResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVChangeError.None;
|
||||
public PlayerKVChangeError Error { get; }
|
||||
public long AttemptSequence { get; }
|
||||
public long Revision { get; }
|
||||
public bool LocalCommitMayHaveSucceeded { get; }
|
||||
internal PlayerKVStateResult(PlayerKVChangeError error, long sequence = 0, long revision = 0, bool uncertain = false)
|
||||
{ Error = error; AttemptSequence = sequence; Revision = revision; LocalCommitMayHaveSucceeded = uncertain; }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVOpenResult
|
||||
{
|
||||
public bool Success => Error == PlayerKVOpenError.None;
|
||||
public PlayerKVOpenError Error { get; }
|
||||
public PlayerKVSyncState State { get; }
|
||||
internal PlayerKVOpenResult(PlayerKVOpenError error, PlayerKVSyncState state = null) { Error = error; State = state; }
|
||||
}
|
||||
|
||||
// Existence is separate from value: an empty string is not a deletion.
|
||||
public sealed class PlayerKVValue : IEquatable<PlayerKVValue>
|
||||
{
|
||||
public static readonly PlayerKVValue Missing = new PlayerKVValue(false, null);
|
||||
public bool Exists { get; }
|
||||
public string Value { get; }
|
||||
PlayerKVValue(bool exists, string value) { Exists = exists; Value = value; }
|
||||
public static PlayerKVValue Present(string value) => new PlayerKVValue(true, value ?? throw new ArgumentNullException(nameof(value)));
|
||||
public bool Equals(PlayerKVValue other) => other != null && Exists == other.Exists && Value == other.Value;
|
||||
public override bool Equals(object obj) => Equals(obj as PlayerKVValue);
|
||||
public override int GetHashCode() => Exists ? (Value.GetHashCode() ^ 397) : 0;
|
||||
internal static PlayerKVValue At(IReadOnlyDictionary<string, string> data, string key) =>
|
||||
data.TryGetValue(key, out var value) ? Present(value) : Missing;
|
||||
}
|
||||
|
||||
public sealed class PlayerKVChange
|
||||
{
|
||||
public string Key { get; }
|
||||
public long Sequence { get; }
|
||||
public PlayerKVValue BaseValue { get; }
|
||||
public PlayerKVValue Target { get; }
|
||||
internal PlayerKVChange(string key, long sequence, PlayerKVValue baseline, PlayerKVValue target)
|
||||
{ Key = key; Sequence = sequence; BaseValue = baseline; Target = target; }
|
||||
internal PlayerKVChange WithBase(PlayerKVValue baseline) => new PlayerKVChange(Key, Sequence, baseline, Target);
|
||||
}
|
||||
|
||||
public sealed class PlayerKVBatch
|
||||
{
|
||||
public string Id { get; }
|
||||
public IReadOnlyList<PlayerKVChange> Items { get; }
|
||||
public bool IsDelete => !Items[0].Target.Exists;
|
||||
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items)
|
||||
{ Id = id; Items = Array.AsReadOnly(items.ToArray()); }
|
||||
}
|
||||
|
||||
public sealed class PlayerKVDiagnosticSnapshot
|
||||
{
|
||||
public long Revision { get; }
|
||||
public long NextSequence { get; }
|
||||
public bool HasBaseline { get; }
|
||||
public bool AuthenticationInvalid { get; }
|
||||
public IReadOnlyDictionary<string, string> Baseline { get; }
|
||||
public IReadOnlyDictionary<string, string> LocalOnly { get; }
|
||||
public IReadOnlyDictionary<string, PlayerKVChange> Pending { get; }
|
||||
public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; }
|
||||
public PlayerKVBatch ActiveBatch { get; }
|
||||
public PlayerKVBatch UnknownBatch { get; }
|
||||
internal PlayerKVDiagnosticSnapshot(KVSnapshot source)
|
||||
{
|
||||
Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline;
|
||||
AuthenticationInvalid = source.AuthenticationInvalid;
|
||||
Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly);
|
||||
Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts);
|
||||
ActiveBatch = source.Active; UnknownBatch = source.Unknown;
|
||||
}
|
||||
static IReadOnlyDictionary<string, T> ReadOnly<T>(Dictionary<string, T> source) =>
|
||||
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
|
||||
}
|
||||
|
||||
internal sealed class KVIdentity
|
||||
{
|
||||
internal readonly string Api, PlayerId, DirectoryPath;
|
||||
internal KVIdentity(string root, Uri api, string playerId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root) || !Path.IsPathFullyQualified(root) || api == null || !api.IsAbsoluteUri ||
|
||||
(api.Scheme != "http" && api.Scheme != "https") || api.UserInfo.Length != 0 || api.Query.Length != 0 || api.Fragment.Length != 0 ||
|
||||
!long.TryParse(playerId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) || id <= 0)
|
||||
throw new ArgumentException("Invalid KV cache identity.");
|
||||
Api = api.AbsoluteUri.TrimEnd('/') + "/";
|
||||
PlayerId = id.ToString(CultureInfo.InvariantCulture);
|
||||
using (var hash = SHA256.Create())
|
||||
{
|
||||
var bytes = hash.ComputeHash(Encoding.UTF8.GetBytes(Api));
|
||||
var fingerprint = string.Concat(bytes.Select(b => b.ToString("x2", CultureInfo.InvariantCulture)));
|
||||
DirectoryPath = Path.Combine(Path.GetFullPath(root), "player-kv", "v1", fingerprint, PlayerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class KVSnapshot
|
||||
{
|
||||
internal long Revision, NextSequence = 1;
|
||||
internal bool HasBaseline, AuthenticationInvalid;
|
||||
internal Dictionary<string, string> Baseline = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, string> LocalOnly = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, PlayerKVChange> Pending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
|
||||
internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
|
||||
internal PlayerKVBatch Active, Unknown;
|
||||
internal KVSnapshot Clone() => new KVSnapshot {
|
||||
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
|
||||
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal), LocalOnly = new Dictionary<string, string>(LocalOnly, StringComparer.Ordinal),
|
||||
Pending = new Dictionary<string, PlayerKVChange>(Pending, StringComparer.Ordinal), Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
|
||||
Active = Active, Unknown = Unknown
|
||||
};
|
||||
internal static bool ValidKey(string key) => !string.IsNullOrEmpty(key) && key.Length <= 128;
|
||||
|
||||
internal string Encode(KVIdentity identity)
|
||||
{
|
||||
var pending = new JArray(Pending.Values.OrderBy(p => p.Sequence).Select(ChangeJson));
|
||||
var conflicts = new JObject();
|
||||
foreach (var pair in Conflicts) conflicts.Add(pair.Key, ValueJson(pair.Value));
|
||||
return new JObject {
|
||||
["formatVersion"] = 1, ["apiBaseUri"] = identity.Api, ["playerId"] = identity.PlayerId,
|
||||
["revision"] = Revision, ["nextSequence"] = NextSequence, ["hasBaseline"] = HasBaseline,
|
||||
["authenticationInvalid"] = AuthenticationInvalid, ["baseline"] = JObject.FromObject(Baseline),
|
||||
["localOnly"] = JObject.FromObject(LocalOnly), ["pending"] = pending, ["conflictObservations"] = conflicts,
|
||||
["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown)
|
||||
}.ToString(Formatting.None);
|
||||
}
|
||||
static JObject ValueJson(PlayerKVValue value) => new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
|
||||
static JObject ChangeJson(PlayerKVChange item) => new JObject {
|
||||
["key"] = item.Key, ["sequence"] = item.Sequence, ["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target)
|
||||
};
|
||||
static JToken BatchJson(PlayerKVBatch batch) => batch == null ? JValue.CreateNull() : (JToken)new JObject {
|
||||
["id"] = batch.Id, ["items"] = new JArray(batch.Items.Select(ChangeJson))
|
||||
};
|
||||
|
||||
internal static KVSnapshot Decode(string text, KVIdentity identity)
|
||||
{
|
||||
JObject root;
|
||||
using (var reader = new JsonTextReader(new StringReader(text)) { DateParseHandling = DateParseHandling.None })
|
||||
{
|
||||
root = JObject.Load(reader, new JsonLoadSettings { DuplicatePropertyNameHandling = DuplicatePropertyNameHandling.Error });
|
||||
if (reader.Read()) throw Invalid();
|
||||
}
|
||||
if (Integer(root, "formatVersion") != 1 || String(root, "apiBaseUri") != identity.Api || String(root, "playerId") != identity.PlayerId) throw Invalid();
|
||||
var result = new KVSnapshot {
|
||||
Revision = Integer(root, "revision"), NextSequence = Integer(root, "nextSequence"),
|
||||
HasBaseline = Boolean(root, "hasBaseline"), AuthenticationInvalid = Boolean(root, "authenticationInvalid"),
|
||||
Baseline = Strings(Field(root, "baseline", JTokenType.Object)), LocalOnly = Strings(Field(root, "localOnly", JTokenType.Object)),
|
||||
Active = Batch(root, "activeBatch"), Unknown = Batch(root, "unknownBatch")
|
||||
};
|
||||
foreach (var item in Field(root, "pending", JTokenType.Array))
|
||||
{
|
||||
var change = Change(item);
|
||||
if (result.Pending.ContainsKey(change.Key)) throw Invalid();
|
||||
result.Pending.Add(change.Key, change);
|
||||
}
|
||||
foreach (var property in ((JObject)Field(root, "conflictObservations", JTokenType.Object)).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name)) throw Invalid();
|
||||
result.Conflicts.Add(property.Name, Value(property.Value));
|
||||
}
|
||||
result.Validate();
|
||||
return result;
|
||||
}
|
||||
internal void Validate()
|
||||
{
|
||||
if (Revision < 1 || NextSequence < 1 || (Active != null && Unknown != null) ||
|
||||
(!HasBaseline && (Baseline.Count != 0 || LocalOnly.Count != 0 || Pending.Count != 0 || Active != null || Unknown != null))) throw Invalid();
|
||||
var sequences = new Dictionary<long, string>();
|
||||
foreach (var item in Pending.Values)
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid();
|
||||
sequences.Add(item.Sequence, item.Key);
|
||||
}
|
||||
foreach (var key in Conflicts.Keys) if (!Pending.ContainsKey(key)) throw Invalid();
|
||||
var batch = Active ?? Unknown;
|
||||
if (batch == null) return;
|
||||
foreach (var item in batch.Items)
|
||||
{
|
||||
if (item.Sequence < 1 || item.Sequence >= NextSequence || !Pending.TryGetValue(item.Key, out var latest) || latest.Sequence < item.Sequence ||
|
||||
(sequences.TryGetValue(item.Sequence, out var key) && key != item.Key) ||
|
||||
!latest.BaseValue.Equals(item.BaseValue) ||
|
||||
(latest.Sequence == item.Sequence && !latest.Target.Equals(item.Target))) throw Invalid();
|
||||
}
|
||||
}
|
||||
static Dictionary<string, string> Strings(JToken value)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var property in ((JObject)value).Properties())
|
||||
{
|
||||
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.String) throw Invalid();
|
||||
result.Add(property.Name, (string)property.Value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
static PlayerKVChange Change(JToken item)
|
||||
{
|
||||
var key = String(item, "key");
|
||||
if (!ValidKey(key)) throw Invalid();
|
||||
return new PlayerKVChange(key, Integer(item, "sequence"), Value(Field(item, "base", JTokenType.Object)), Value(Field(item, "target", JTokenType.Object)));
|
||||
}
|
||||
static PlayerKVValue Value(JToken item)
|
||||
{
|
||||
var exists = Boolean(item, "exists");
|
||||
var value = Field(item, "value", exists ? JTokenType.String : JTokenType.Null);
|
||||
return exists ? PlayerKVValue.Present((string)value) : PlayerKVValue.Missing;
|
||||
}
|
||||
static PlayerKVBatch Batch(JToken parent, string name)
|
||||
{
|
||||
var node = parent[name];
|
||||
if (node == null) throw Invalid();
|
||||
if (node.Type == JTokenType.Null) return null;
|
||||
var id = String(node, "id");
|
||||
if (!Guid.TryParseExact(id, "N", out _)) throw Invalid();
|
||||
var items = Field(node, "items", JTokenType.Array).Select(Change).ToArray();
|
||||
if (items.Length == 0 || items.Length > 10 || items.Select(i => i.Key).Distinct(StringComparer.Ordinal).Count() != items.Length ||
|
||||
items.Select(i => i.Sequence).Distinct().Count() != items.Length || items.Any(i => i.Target.Exists != items[0].Target.Exists) ||
|
||||
!items.Select(i => i.Sequence).SequenceEqual(items.Select(i => i.Sequence).OrderBy(s => s))) throw Invalid();
|
||||
return new PlayerKVBatch(id, items);
|
||||
}
|
||||
static JToken Field(JToken parent, string name, JTokenType type)
|
||||
{
|
||||
if (!(parent is JObject obj) || obj[name] == null || obj[name].Type != type) throw Invalid();
|
||||
return obj[name];
|
||||
}
|
||||
static string String(JToken parent, string name) => (string)Field(parent, name, JTokenType.String);
|
||||
static long Integer(JToken parent, string name) => (long)Field(parent, name, JTokenType.Integer);
|
||||
static bool Boolean(JToken parent, string name) => (bool)Field(parent, name, JTokenType.Boolean);
|
||||
internal static JsonException Invalid() => new JsonException("Invalid KV cache snapshot.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c949512a03dc431b94569c20f06102af
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
203
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs
Normal file
203
Client/Assets/Scripts/Services/PlayerData/PlayerKVSyncState.cs
Normal file
@@ -0,0 +1,203 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Synchronous, fixed-identity cache state. No HTTP, gameplay initialization or timers.
|
||||
/// All transitions and disk writes share a gate. The phase-3B owner must drain its
|
||||
/// admitted network operations before disposing this state and its filesystem lease.
|
||||
/// </summary>
|
||||
public sealed class PlayerKVSyncState : IDisposable
|
||||
{
|
||||
readonly object gate = new object();
|
||||
readonly PlayerKVCacheStore store;
|
||||
KVSnapshot snapshot;
|
||||
PlayerKVSyncStatus status;
|
||||
public PlayerKVSyncStatus Status { get { lock (gate) return status; } }
|
||||
public string ApiBaseUri { get; }
|
||||
public string PlayerId { get; }
|
||||
|
||||
PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot)
|
||||
{
|
||||
ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot;
|
||||
// Authentication belongs to the old owner. A new owner must obtain a fresh
|
||||
// successful Get before Ready; this never clears a persisted unknown batch.
|
||||
snapshot.AuthenticationInvalid = false;
|
||||
// A recovered active batch might have reached the server. Never acknowledge it by guessing.
|
||||
if (snapshot.Active != null) { snapshot.Unknown = snapshot.Active; snapshot.Active = null; }
|
||||
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded;
|
||||
}
|
||||
|
||||
public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null);
|
||||
internal static PlayerKVOpenResult Open(string root, Uri api, string playerId, Action<KVCacheWriteStage> fault)
|
||||
{
|
||||
KVIdentity identity;
|
||||
try { identity = new KVIdentity(root, api, playerId); }
|
||||
catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException)
|
||||
{ return new PlayerKVOpenResult(PlayerKVOpenError.Validation); }
|
||||
var error = PlayerKVCacheStore.Open(identity, fault, out var store, out var snapshot);
|
||||
return error == PlayerKVOpenError.None ? new PlayerKVOpenResult(error, new PlayerKVSyncState(identity, store, snapshot)) : new PlayerKVOpenResult(error);
|
||||
}
|
||||
|
||||
public PlayerKVDiagnosticSnapshot Inspect() { lock (gate) return new PlayerKVDiagnosticSnapshot(snapshot); }
|
||||
|
||||
public IReadOnlyDictionary<string, string> ReadLocal()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready) throw new InvalidOperationException("KV data is not ready.");
|
||||
var result = new Dictionary<string, string>(snapshot.Baseline, StringComparer.Ordinal);
|
||||
foreach (var item in snapshot.Pending.Values) Apply(result, item.Key, item.Target);
|
||||
foreach (var pair in snapshot.LocalOnly) result[pair.Key] = pair.Value;
|
||||
return new ReadOnlyDictionary<string, string>(result);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
|
||||
public PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (remote == null || remote.Any(p => !KVSnapshot.ValidKey(p.Key) || p.Value == null)) return Invalid();
|
||||
var copy = snapshot.Clone();
|
||||
copy.Conflicts.Clear();
|
||||
if (copy.Unknown != null)
|
||||
{
|
||||
foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
|
||||
return Commit(copy, PlayerKVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
foreach (var item in copy.Pending.Values.ToArray())
|
||||
{
|
||||
var observed = PlayerKVValue.At(remote, item.Key);
|
||||
if (observed.Equals(item.Target)) copy.Pending.Remove(item.Key);
|
||||
else if (!observed.Equals(item.BaseValue)) copy.Conflicts[item.Key] = observed;
|
||||
}
|
||||
copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
|
||||
copy.HasBaseline = true;
|
||||
return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult RecordReadFailure(bool unauthorized = false)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active != null) return Blocked();
|
||||
if (unauthorized)
|
||||
{
|
||||
var copy = snapshot.Clone(); copy.AuthenticationInvalid = true;
|
||||
return Commit(copy, copy.Unknown == null ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.UnknownOutcome);
|
||||
}
|
||||
status = snapshot.Unknown != null ? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.ReadFailed;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult Update(string key, string value, bool remoteStore = true) =>
|
||||
Edit(key, value == null ? null : PlayerKVValue.Present(value), remoteStore);
|
||||
public PlayerKVStateResult Delete(string key) => Edit(key, PlayerKVValue.Missing, true);
|
||||
|
||||
PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (!KVSnapshot.ValidKey(key) || target == null || snapshot.NextSequence == long.MaxValue) return Invalid();
|
||||
var copy = snapshot.Clone();
|
||||
var sequence = copy.NextSequence++;
|
||||
if (remote)
|
||||
{
|
||||
var baseline = copy.Pending.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
|
||||
copy.Pending[key] = new PlayerKVChange(key, sequence, baseline, target);
|
||||
copy.LocalOnly.Remove(key);
|
||||
}
|
||||
else copy.LocalOnly[key] = target.Value;
|
||||
return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
batch = null;
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid) return Blocked();
|
||||
if (throughSequence < 0) return Invalid();
|
||||
var ordered = snapshot.Pending.Values.Where(p => p.Sequence <= throughSequence).OrderBy(p => p.Sequence).ToArray();
|
||||
if (ordered.Length == 0) return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
|
||||
var copy = snapshot.Clone();
|
||||
copy.Active = new PlayerKVBatch(Guid.NewGuid().ToString("N"), ordered.TakeWhile(p => p.Target.Exists == ordered[0].Target.Exists).Take(10));
|
||||
var result = Commit(copy, PlayerKVSyncStatus.Ready);
|
||||
if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable.
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerKVStateResult ConfirmBatch(string batchId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked();
|
||||
var copy = snapshot.Clone();
|
||||
foreach (var item in copy.Active.Items)
|
||||
{
|
||||
Apply(copy.Baseline, item.Key, item.Target);
|
||||
var current = copy.Pending[item.Key];
|
||||
if (current.Sequence == item.Sequence) copy.Pending.Remove(item.Key);
|
||||
else copy.Pending[item.Key] = current.WithBase(item.Target);
|
||||
}
|
||||
copy.Active = null;
|
||||
return Commit(copy, PlayerKVSyncStatus.Ready);
|
||||
}
|
||||
}
|
||||
|
||||
// mayHaveCommitted=false is only valid with positive evidence of no dispatch.
|
||||
public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked();
|
||||
var copy = snapshot.Clone();
|
||||
copy.AuthenticationInvalid |= unauthorized;
|
||||
if (mayHaveCommitted) copy.Unknown = copy.Active;
|
||||
copy.Active = null;
|
||||
return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome :
|
||||
copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError;
|
||||
PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0)
|
||||
{
|
||||
if (snapshot.Revision == long.MaxValue)
|
||||
{ status = PlayerKVSyncStatus.StorageError; return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, snapshot.Revision); }
|
||||
copy.Revision = snapshot.Revision + 1;
|
||||
if (!store.Save(copy, out var uncertain))
|
||||
{
|
||||
status = PlayerKVSyncStatus.StorageError;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.StorageError, sequence, copy.Revision, uncertain);
|
||||
}
|
||||
snapshot = copy; status = nextStatus;
|
||||
return new PlayerKVStateResult(PlayerKVChangeError.None, sequence, copy.Revision);
|
||||
}
|
||||
static void Apply(Dictionary<string, string> data, string key, PlayerKVValue value)
|
||||
{ if (value.Exists) data[key] = value.Value; else data.Remove(key); }
|
||||
PlayerKVStateResult Blocked() => new PlayerKVStateResult(PlayerKVChangeError.Blocked, revision: snapshot.Revision);
|
||||
PlayerKVStateResult Invalid() => new PlayerKVStateResult(PlayerKVChangeError.Validation, revision: snapshot.Revision);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
if (status == PlayerKVSyncStatus.Closed) return;
|
||||
status = PlayerKVSyncStatus.Closed;
|
||||
store.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0aabf3ee88684f80ba8770cbf96a9be8
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -5,6 +5,7 @@ using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
using System.Net;
|
||||
using asap.core;
|
||||
using Microsoft.AspNetCore.Http.Connections;
|
||||
using Microsoft.AspNetCore.SignalR.Client;
|
||||
using Newtonsoft.Json;
|
||||
using UniRx;
|
||||
@@ -22,184 +23,159 @@ public interface IRTService
|
||||
public Task<string> Publish(string methodName, string arg1, string arg2);
|
||||
//public void Reset();
|
||||
public void Release();
|
||||
public Task ReleaseAsync();
|
||||
}
|
||||
|
||||
public class SignalRService : IRTService
|
||||
{
|
||||
private HubConnection _hubConnection;
|
||||
private ICustomServerMgr _serverMgr;
|
||||
private HashSet<Type> _bindedEvent;
|
||||
private IEventAggregator _eventAgg;
|
||||
private string _url;
|
||||
private Dictionary<string, Action<string>> _cachedEvents;
|
||||
private readonly ICustomServerMgr _serverMgr;
|
||||
private readonly IEventAggregator _eventAgg = new EventAggregator();
|
||||
private readonly string _url;
|
||||
private readonly Dictionary<string, Action<string>> handlers = new Dictionary<string, Action<string>>();
|
||||
private readonly System.Threading.SemaphoreSlim lifecycle = new System.Threading.SemaphoreSlim(1, 1);
|
||||
private IDisposable loginSubscription;
|
||||
private bool released;
|
||||
private int connectionVersion;
|
||||
private System.Threading.CancellationTokenSource connecting;
|
||||
|
||||
public SignalRService(ICustomServerMgr serverMgr, IConfig config)
|
||||
{
|
||||
this._serverMgr = serverMgr;
|
||||
var baseUrl = config.Get<string>(GConstant.K_Event_API_URL, string.Empty);
|
||||
#if UNITY_EDITOR
|
||||
UnityEngine.Assertions.Assert.IsTrue(!string.IsNullOrEmpty(baseUrl));
|
||||
#endif
|
||||
_url = baseUrl + "gamehub";
|
||||
_eventAgg = new EventAggregator();
|
||||
_cachedEvents = new Dictionary<string, Action<string>>();
|
||||
_bindedEvent = new HashSet<Type>();
|
||||
_serverMgr = serverMgr;
|
||||
_url = new Uri(serverMgr.ApiBaseUri, "gamehub").AbsoluteUri;
|
||||
_serverMgr.SessionChanged += OnSessionChanged;
|
||||
}
|
||||
|
||||
public void WaitForAPISvrLogin()
|
||||
{
|
||||
GContext.OnEvent<game.EvtAPISvrLoginSuccess>().Subscribe(OnApiSvrLoginSuccess);
|
||||
if (released || loginSubscription != null) return;
|
||||
loginSubscription = GContext.OnEvent<EvtAPISvrLoginSuccess>().Subscribe(OnApiSvrLoginSuccess);
|
||||
}
|
||||
|
||||
private async void OnApiSvrLoginSuccess(EvtAPISvrLoginSuccess evt)
|
||||
{
|
||||
try{
|
||||
Reset();
|
||||
await Connect();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogException(ex);
|
||||
}
|
||||
try { await Connect(); }
|
||||
catch (Exception ex) { Debug.LogException(ex); }
|
||||
}
|
||||
|
||||
public void Release()
|
||||
private async void OnSessionChanged()
|
||||
{
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
_hubConnection.StopAsync();
|
||||
_hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
|
||||
if (_bindedEvent != null)
|
||||
{
|
||||
_bindedEvent.Clear();
|
||||
_bindedEvent = null;
|
||||
}
|
||||
|
||||
if (_eventAgg != null)
|
||||
{
|
||||
_eventAgg = null;
|
||||
}
|
||||
|
||||
if(_cachedEvents != null)
|
||||
{
|
||||
_cachedEvents.Clear();
|
||||
_cachedEvents = null;
|
||||
}
|
||||
connectionVersion++;
|
||||
connecting?.Cancel();
|
||||
try { await Disconnect(); }
|
||||
catch (Exception ex) { Debug.LogException(ex); }
|
||||
}
|
||||
|
||||
|
||||
private void Reset()
|
||||
private async Task CloseConnection()
|
||||
{
|
||||
if (_hubConnection != null)
|
||||
{
|
||||
_hubConnection.StopAsync();
|
||||
_hubConnection.DisposeAsync();
|
||||
_hubConnection = null;
|
||||
}
|
||||
|
||||
var cookie = _serverMgr.Cookie;
|
||||
_hubConnection = new HubConnectionBuilder()
|
||||
.WithUrl(_url, option => option.Cookies = cookie)
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
|
||||
_hubConnection.Reconnected += OnHubConnected;
|
||||
|
||||
_hubConnection.On<string,string, string>("ForceQuit", ForceQuit);
|
||||
|
||||
if (_bindedEvent != null)
|
||||
{
|
||||
_bindedEvent.Clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
_bindedEvent = new HashSet<Type>();
|
||||
}
|
||||
var previous = _hubConnection;
|
||||
_hubConnection = null;
|
||||
if (previous == null) return;
|
||||
try { await previous.StopAsync(); }
|
||||
finally { await previous.DisposeAsync(); }
|
||||
}
|
||||
|
||||
private Task OnHubConnected(string connectionId)
|
||||
public async void Release()
|
||||
{
|
||||
if(_cachedEvents.Count > 0)
|
||||
{
|
||||
foreach(var kv in _cachedEvents)
|
||||
{
|
||||
_hubConnection.On<string>(kv.Key, kv.Value);
|
||||
}
|
||||
_cachedEvents.Clear();
|
||||
}
|
||||
_hubConnection.Reconnected -= OnHubConnected;
|
||||
return Task.CompletedTask;
|
||||
try { await ReleaseAsync(); }
|
||||
catch (Exception ex) { Debug.LogException(ex); }
|
||||
}
|
||||
|
||||
public bool IsConnected => _hubConnection != null && _hubConnection.State == HubConnectionState.Connected;
|
||||
public async Task ReleaseAsync()
|
||||
{
|
||||
released = true;
|
||||
connectionVersion++;
|
||||
connecting?.Cancel();
|
||||
loginSubscription?.Dispose();
|
||||
loginSubscription = null;
|
||||
_serverMgr.SessionChanged -= OnSessionChanged;
|
||||
await lifecycle.WaitAsync();
|
||||
try { await CloseConnection(); handlers.Clear(); }
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
private bool IsCurrent(HubConnection hub, int version, int session) =>
|
||||
!released && version == connectionVersion && session == _serverMgr.SessionVersion && ReferenceEquals(hub, _hubConnection);
|
||||
|
||||
public bool IsConnected => !released && _hubConnection != null && _hubConnection.State == HubConnectionState.Connected;
|
||||
|
||||
public async Task Connect()
|
||||
{
|
||||
if(_hubConnection == null)
|
||||
var session = _serverMgr.SessionVersion;
|
||||
var version = connectionVersion;
|
||||
var cookies = _serverMgr.Cookie;
|
||||
await lifecycle.WaitAsync();
|
||||
try
|
||||
{
|
||||
Debug.LogWarning("[RTService] hubConnection is null");
|
||||
return;
|
||||
if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return;
|
||||
if (_hubConnection != null && _hubConnection.State != HubConnectionState.Disconnected) return;
|
||||
await CloseConnection();
|
||||
if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return;
|
||||
var hub = new HubConnectionBuilder()
|
||||
.WithUrl(_url, option =>
|
||||
{
|
||||
option.Cookies = cookies;
|
||||
option.SkipNegotiation = true;
|
||||
option.Transports = HttpTransportType.WebSockets;
|
||||
})
|
||||
.WithAutomaticReconnect()
|
||||
.Build();
|
||||
_hubConnection = hub;
|
||||
hub.On<string, string, string>("ForceQuit", (title, info, button) =>
|
||||
{
|
||||
if (IsCurrent(hub, version, session)) ForceQuit(title, info, button, version, session);
|
||||
});
|
||||
foreach (var handler in handlers)
|
||||
Bind(hub, version, session, handler.Key, handler.Value);
|
||||
connecting?.Dispose();
|
||||
connecting = new System.Threading.CancellationTokenSource();
|
||||
try { await hub.StartAsync(connecting.Token); }
|
||||
catch { await CloseConnection(); throw; }
|
||||
if (!IsCurrent(hub, version, session)) { await CloseConnection(); return; }
|
||||
Debug.Log("[RTService] Connected to SignalR server True");
|
||||
}
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
//UnityEngine.Assertions.Assert.IsNotNull(_hubConnection, "[RTService] hubConnection is null");
|
||||
if (_hubConnection.State == HubConnectionState.Disconnected)
|
||||
private void Bind(HubConnection hub, int version, int session, string name, Action<string> handler)
|
||||
{
|
||||
hub.On<string>(name, data =>
|
||||
{
|
||||
await _hubConnection.StartAsync();
|
||||
Debug.Log($"[RTService] Connected to SignalR server {_hubConnection.State == HubConnectionState.Connected}");
|
||||
}
|
||||
if (IsCurrent(hub, version, session)) handler(data);
|
||||
});
|
||||
}
|
||||
|
||||
public async Task Disconnect()
|
||||
{
|
||||
if(_hubConnection == null)
|
||||
{
|
||||
Debug.LogWarning("[RTService] hubConnection is null");
|
||||
return;
|
||||
}
|
||||
|
||||
//UnityEngine.Assertions.Assert.IsNotNull(_hubConnection, "[RTService] hubConnection is null");
|
||||
if (_hubConnection.State == HubConnectionState.Connected)
|
||||
{
|
||||
await _hubConnection.StopAsync();
|
||||
Debug.Log($"[RTService] Disconnected to SignalR server {_hubConnection.State == HubConnectionState.Disconnected}");
|
||||
}
|
||||
await lifecycle.WaitAsync();
|
||||
try { await CloseConnection(); }
|
||||
finally { lifecycle.Release(); }
|
||||
}
|
||||
|
||||
public IDisposable OnEvent<T>(Action<T> handler)
|
||||
{
|
||||
var methodName = typeof(T).Name;
|
||||
|
||||
if(!_bindedEvent.Contains(typeof(T)))
|
||||
if (released) throw new ObjectDisposedException(nameof(SignalRService));
|
||||
var name = typeof(T).Name;
|
||||
if (!handlers.ContainsKey(name))
|
||||
{
|
||||
_bindedEvent.Add(typeof(T));
|
||||
if(IsConnected)
|
||||
{
|
||||
_hubConnection.On<string>(methodName, data => {
|
||||
var result =JsonConvert.DeserializeObject<T>(data);
|
||||
_eventAgg.Publish(result);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
_cachedEvents[methodName] = data => {
|
||||
|
||||
var result =JsonConvert.DeserializeObject<T>(data);
|
||||
_eventAgg.Publish(result);
|
||||
};
|
||||
}
|
||||
Action<string> dispatch = data => _eventAgg.Publish(JsonConvert.DeserializeObject<T>(data));
|
||||
handlers.Add(name, dispatch);
|
||||
if (_hubConnection != null)
|
||||
Bind(_hubConnection, connectionVersion, _serverMgr.SessionVersion, name, dispatch);
|
||||
}
|
||||
|
||||
return _eventAgg.GetEvent<T>().Subscribe(handler);
|
||||
}
|
||||
|
||||
|
||||
public async Task<string> Publish(string methodName, string arg1)
|
||||
{
|
||||
var hub = _hubConnection;
|
||||
var version = connectionVersion;
|
||||
var session = _serverMgr.SessionVersion;
|
||||
if (hub == null || !IsCurrent(hub, version, session)) return null;
|
||||
try
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string>(methodName, arg1);
|
||||
var result = await hub.InvokeAsync<string>(methodName, arg1);
|
||||
return IsCurrent(hub, version, session) ? result : null;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -210,9 +186,14 @@ public class SignalRService : IRTService
|
||||
|
||||
public async Task<string> Publish(string methodName, string arg1, string arg2)
|
||||
{
|
||||
var hub = _hubConnection;
|
||||
var version = connectionVersion;
|
||||
var session = _serverMgr.SessionVersion;
|
||||
if (hub == null || !IsCurrent(hub, version, session)) return null;
|
||||
try
|
||||
{
|
||||
return await _hubConnection.InvokeAsync<string>(methodName, arg1, arg2);
|
||||
var result = await hub.InvokeAsync<string>(methodName, arg1, arg2);
|
||||
return IsCurrent(hub, version, session) ? result : null;
|
||||
}
|
||||
catch(Exception e)
|
||||
{
|
||||
@@ -221,15 +202,17 @@ public class SignalRService : IRTService
|
||||
}
|
||||
}
|
||||
|
||||
private async void ForceQuit(string titleKey, string infoKey, string btnKey)
|
||||
private async void ForceQuit(string titleKey, string infoKey, string btnKey, int version, int session)
|
||||
{
|
||||
await Awaiters.NextFrame;
|
||||
if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return;
|
||||
GameObject go = await UIManager.Instance.ShowUI(UITypes.NoticeConfirmPopupPanel);
|
||||
if (released || version != connectionVersion || session != _serverMgr.SessionVersion) return;
|
||||
var noticeConfirmPopupPanel = go.GetComponent<NoticeConfirmPopupPanel>();
|
||||
string title = LocalizationMgr.GetText(titleKey ?? "UI_NoticePopupPanel_3");
|
||||
string info = LocalizationMgr.GetText(infoKey ?? "UI_NoticePopupPanel_4");
|
||||
string label = LocalizationMgr.GetText(btnKey ?? "UI_COMMON_confirm");
|
||||
noticeConfirmPopupPanel.Init(1, title, info, onClickLeft: Restart, onClose: Restart, btn_left_text: label);
|
||||
noticeConfirmPopupPanel.Init(1, title, info, onClickLeft: () => { if (!released && version == connectionVersion && session == _serverMgr.SessionVersion) Restart(); }, onClose: () => { if (!released && version == connectionVersion && session == _serverMgr.SessionVersion) Restart(); }, btn_left_text: label);
|
||||
}
|
||||
|
||||
private async void Restart()
|
||||
|
||||
291
Client/Assets/Scripts/Services/UserService.KVFishingReward.cs
Normal file
291
Client/Assets/Scripts/Services/UserService.KVFishingReward.cs
Normal file
@@ -0,0 +1,291 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using cfg;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
internal sealed class FishingRewardPlan
|
||||
{
|
||||
internal FishingData Data;
|
||||
internal int Fish, Item, Area, Rod, Map, Mag;
|
||||
internal string Activities;
|
||||
internal string MapKey => "Map_5_"+Area;
|
||||
}
|
||||
internal sealed class FishingRewardAction
|
||||
{
|
||||
internal readonly FishingAction Consumption;
|
||||
internal readonly object Controller;
|
||||
internal readonly FishingRewardPlan Plan;
|
||||
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
|
||||
internal readonly HashSet<string> Segments=new HashSet<string>(StringComparer.Ordinal);
|
||||
internal object Panel;
|
||||
internal WriteBatch Segment;
|
||||
internal string SegmentName;
|
||||
internal bool Ending,Applying,Sealed,Confirmed,Presented,Cancelled;
|
||||
internal int GuideNotifications,BeforeFishCount;
|
||||
internal FishingRewardAction(FishingAction consumption,object controller,FishingRewardPlan plan)
|
||||
{ Consumption=consumption; Controller=controller; Plan=plan; }
|
||||
}
|
||||
static FishingRewardPlan rewardPlan;
|
||||
static FishingRewardAction rewardAction;
|
||||
static readonly ConditionType[] rewardConditions={ConditionType.ConsumeEnergy,ConditionType.PerfectStrikeCount,
|
||||
ConditionType.GetFishCount,ConditionType.CashFromFishing,ConditionType.MaxCashFromFishing,
|
||||
ConditionType.GetFishWeight,ConditionType.GetFishID,ConditionType.GetBoosFish,ConditionType.GetBiggerThanHugeFishCount};
|
||||
static readonly HashSet<string> rewardTaskKeys=new HashSet<string>(StringComparer.Ordinal)
|
||||
{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"};
|
||||
|
||||
static string ActivityStamp(Tables tables)
|
||||
{
|
||||
var manager=GContext.container.Resolve<Activity.ActivityManager>();
|
||||
var scopes=GContext.container.Resolve<Activity.ActivityScopeManager>();
|
||||
return string.Join(";",tables.TbFishingEvent.DataList.GroupBy(e=>(e.Type,e.SubType)).Select(g=>g.First())
|
||||
.Select(e=>e.Type+":"+e.SubType+":"+manager.GetSentry(e)?.CurrentEventConfig?.ID+":"+scopes.HasActiveScope((e.Type,e.SubType))));
|
||||
}
|
||||
static readonly HashSet<string> specialRewardGuides=new HashSet<string>(StringComparer.Ordinal)
|
||||
{"FishSkill01","Pollution01","FishSkill02","FishSkill03","FishSkill04","Egyptian01","EasterFish01"};
|
||||
internal static void CheckRewardGuide(FishingData data=null)
|
||||
{
|
||||
var guide=GContext.container.Resolve<GuideDataCenter>();
|
||||
if(guide.curDefine!=null || guide.curGroupDefine!=null || guide.guidanceGroupDefines==null ||
|
||||
guide.guidanceGroupDefines.Any(g=>g==null || g.guidanceDefines==null || g.guidanceDefines.Any(n=>n==null)))
|
||||
throw Stop("Reward requires inactive and complete guide definitions.");
|
||||
var groups=guide.guidanceGroupDefines.Where(g=>g.guidanceDefines.Any(n=>n.panelName=="FishingPanel" ||
|
||||
n.panelName=="FishingPanel_Advanced" || n.panelName=="FishingRewardPanel"))
|
||||
.OrderBy(g=>g.groupName.ToString(),StringComparer.Ordinal).ToArray();
|
||||
var asset=Path.Combine(Application.dataPath,"ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
|
||||
var baseline=File.ReadAllBytes(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVFishingRewardTests/guide-baseline-projection.jsonl")));
|
||||
if(PlayerKVLoopPermit.Hash(File.ReadAllBytes(asset))!="5926124E5633166CC1B91569B8A71AB2C388AA3446A736B3E7E42B3EBD9F3D3A" ||
|
||||
PlayerKVLoopPermit.Hash(baseline)!="A3D056313FE0F40D395FE92F97BEDE31BA47D8D79A3C4C28781395A43118E89C" || groups.Length!=11 ||
|
||||
string.Join("\n",groups.Select(g=>JsonUtility.ToJson(g)))!=System.Text.Encoding.UTF8.GetString(baseline))
|
||||
throw Stop("Reward guide configuration differs from the reviewed full-node projection.");
|
||||
foreach(var g in groups)
|
||||
if(!guide.GetIsFinish(g.groupName.ToString()) && (!specialRewardGuides.Contains(g.groupName.ToString()) ||
|
||||
g.TriggerConditionsType!=GuideTriggerConditions.None || g.guidanceDefines.Length==0 || g.guidanceDefines[0].panelName!="FishingPanel_Advanced"))
|
||||
throw Stop("Reward requires completed ordinary fishing guides.");
|
||||
data=data??rewardPlan?.Data;
|
||||
if(data?.curFishData?.FishSKillList==null || data.curFishData.FishEscape==null || data.curFishData.FishSKillList.Any(x=>x==null) ||
|
||||
data.fishSpSkillData!=null || data.superDashSpSkillData!=null || data.dirtyStuckSpSkillData!=null)
|
||||
throw Stop("Reward requires an ordinary fish without active special skills.");
|
||||
var tables=GContext.container.Resolve<Tables>();
|
||||
var queried=data.curFishData.FishSKillList.Concat(new[]{data.curFishData.FishEscape}).Select(x=>x.SpSkillID).Distinct().OrderBy(x=>x).ToArray();
|
||||
bool invalid=false;
|
||||
foreach(var id in queried) { var resolved=tables.TbFishSpSkill.GetOrDefault(id); if(id>0 || resolved!=null)invalid=true; }
|
||||
Audit("fishing-reward-special-skill-check",new{queriedIds=queried,invalid});
|
||||
if(invalid) throw Stop("Reward selected fish has a special skill; no consumption is allowed.");
|
||||
}
|
||||
static string EventSignature(FishingEvent e) => JsonConvert.SerializeObject(new{e.ID,e.Type,e.SubType,e.RedirectID,
|
||||
e.IsABTest,e.Channel,e.ConditionRelation,e.PopupPriority,e.PopupType,
|
||||
conditions=e.ConditionList.Select(c=>new{c.Condition,c.Param}),time=e.TimeDefinition.ToString()});
|
||||
static void CheckRewardConfig(Tables tables)
|
||||
{
|
||||
var path=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData/tbfishingevent.json"));
|
||||
var bytes=File.ReadAllBytes(path);
|
||||
if(PlayerKVLoopPermit.Hash(bytes)!="C8A4A9794E9390C94B9C8F1927B3E718B3EB9F784F0889508E1E576DD4B77A2D")
|
||||
throw Stop("Reward event configuration differs from the reviewed baseline.");
|
||||
var baseline=new TbFishingEvent(SimpleJSON.JSON.Parse(System.Text.Encoding.UTF8.GetString(bytes)));
|
||||
var relevant=tables.TbFishingEvent.DataList.Where(e=>e.ConditionList.Any(c=>rewardConditions.Contains(c.Condition))).ToArray();
|
||||
if(relevant.Length!=19 || tables.TbTriggerPackManager.DataList.Any(t=>rewardConditions.Contains(t.Condition)))
|
||||
throw Stop("Reward activates an unreviewed event or trigger pack.");
|
||||
foreach(var e in relevant)
|
||||
{
|
||||
var old=baseline.GetOrDefault(e.ID);
|
||||
if(old==null || EventSignature(old)!=EventSignature(e)) throw Stop("Reward event fields changed.");
|
||||
if(e.ID==10100000 || e.ID==10200000 || e.ID==11300000) continue;
|
||||
if(e.ID<30200350 || e.ID>30200365 || !(e.TimeDefinition is LimitedTime time) ||
|
||||
!DateTime.TryParse(time.EndTime,CultureInfo.InvariantCulture,DateTimeStyles.None,out var end) ||
|
||||
ZZTimeHelper.UtcNow() < end.AddSeconds(120)) throw Stop("Reward event must be outside its reviewed activity window.");
|
||||
}
|
||||
}
|
||||
static void CheckRewardEnvironment(FishingData data,bool checkConfig)
|
||||
{
|
||||
var player=GContext.container.Resolve<PlayerData>();
|
||||
var fish=GContext.container.Resolve<PlayerFishData>();
|
||||
var events=GContext.container.Resolve<FishingEventData>();
|
||||
var challenge=GContext.container.Resolve<FishingChallengeManager>();
|
||||
if(data==null || data.curFishData==null || data.fishItem==null || !data.IsGeneralFish() || data.IsFree ||
|
||||
data.IsRetry || data.IsDuel || data.fixedFishList!=null || data.IsPackItemFish() || data.IsEventGameFish())
|
||||
throw Stop("Reward supports one ordinary paid fish only.");
|
||||
var tables=GContext.container.Resolve<Tables>();
|
||||
if(tables.TbEventSlapSpinach.TryGetByItemID(data.fishItem.ID,out _)) throw Stop("Slap reward is outside this run.");
|
||||
var cruise=fish.GetFishSwimToMap(data.MapId);
|
||||
if((cruise!=-1 && cruise!=0) || events.collectingTargetInit!=null ||
|
||||
GContext.container.Resolve<CollectingData>().GetAddCollecting()!=0 ||
|
||||
GContext.container.Resolve<PiggyBankPackData>().IsPackActivated || GContext.container.Resolve<BargainPackData>().IsPackActivated)
|
||||
throw Stop("Additional collection, cruise or pack rewards require separate review.");
|
||||
if(challenge.fishingChallengeData!=null && challenge.ChallengeState()!=0 && challenge.ChallengeState()!=3 && challenge.IsTimeAllow())
|
||||
throw Stop("Active challenge rewards require separate review.");
|
||||
if(player._fishingFrequency<3 || !player.IsOpenMagnification || !fish.IsOpenPiercing)
|
||||
throw Stop("Reward requires already unlocked basic fishing.");
|
||||
CheckRewardGuide(data);
|
||||
if(checkConfig) CheckRewardConfig(tables);
|
||||
}
|
||||
internal static void PrepareFishingReward(FishingAction action,FishingData data)
|
||||
{
|
||||
if(Permit?.AllowsReward!=true) return;
|
||||
if(rewardPlan!=null || !FishingCurrent(action)) throw Stop("Reward preflight already used or stale.");
|
||||
CheckRewardEnvironment(data,true);
|
||||
var p=GContext.container.Resolve<PlayerData>();
|
||||
var plan=new FishingRewardPlan{Data=data,Fish=data.curFishData.ID,Item=data.fishItem.ID,Area=data.curFishData.AreaID,
|
||||
Rod=data.RodId,Map=p.lastMapId,Mag=p.magnification,Activities=ActivityStamp(GContext.container.Resolve<Tables>())};
|
||||
if(!Permit.AllowedKeys.Contains(plan.MapKey) || Read(plan.MapKey)==null) throw Stop("Reward requires an existing backed-up fish map key.");
|
||||
rewardPlan=plan;
|
||||
Audit("fishing-reward-preflight",new{plan.Fish,plan.Item,plan.Area,plan.Rod,plan.Map,plan.Mag});
|
||||
}
|
||||
static void CheckRewardContext()
|
||||
{
|
||||
var p=rewardPlan; var player=GContext.container.Resolve<PlayerData>();
|
||||
if(p==null || p.Data.curFishData==null || p.Data.fishItem==null || p.Data.curFishData.ID!=p.Fish || p.Data.fishItem.ID!=p.Item ||
|
||||
p.Data.curFishData.AreaID!=p.Area || p.Data.RodId!=p.Rod || player.lastMapId!=p.Map || player.magnification!=p.Mag)
|
||||
throw Stop("Reward fishing context changed.");
|
||||
CheckRewardEnvironment(p.Data,false);
|
||||
if(p.Activities!=ActivityStamp(GContext.container.Resolve<Tables>())) throw Stop("Activity changed during fishing reward.");
|
||||
}
|
||||
internal static bool RewardRunCurrent => RewardCurrent(rewardAction);
|
||||
internal static bool RewardCanAdvance => !Enabled || (FishingCurrent(fishingAction) &&
|
||||
(rewardAction==null || (!rewardAction.Cancelled && !rewardAction.Sealed)));
|
||||
internal static bool BeginRewardDrawing(object controller)
|
||||
{
|
||||
if(Permit?.AllowsReward!=true || !FishingCurrent(fishingAction) || !fishingAction.Confirmed || !fishingAction.Continued)
|
||||
throw Stop("A confirmed v3 fishing action is required before drawing.");
|
||||
if(rewardAction!=null) { Audit("duplicate-suppressed",new{point="draw"}); return false; }
|
||||
CheckRewardContext();
|
||||
rewardAction=new FishingRewardAction(fishingAction,controller,rewardPlan);
|
||||
return true;
|
||||
}
|
||||
internal static bool BeginRewardEnding(object controller,bool success)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(a==null || !ReferenceEquals(controller,a.Controller) || !RewardCurrent(a)) throw Stop("Reward ending is stale.");
|
||||
if(a.Ending) { Audit("duplicate-suppressed",new{point="fish-end"}); return false; }
|
||||
a.Ending=true;
|
||||
if(!success) throw Stop("Fishing failed; reward run stops without retry or refund.");
|
||||
CheckRewardContext(); return true;
|
||||
}
|
||||
internal static FishingRewardAction BindRewardPanel(object panel)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || !a.Ending) throw Stop("Reward panel has no completed catch.");
|
||||
if(a.Applying) { Audit("duplicate-suppressed",new{point="reward-panel"}); return null; }
|
||||
CheckRewardContext();
|
||||
a.Applying=true; a.Panel=panel;
|
||||
var fish=GContext.container.Resolve<PlayerFishData>();
|
||||
var map=JsonConvert.DeserializeObject<Dictionary<int,string>>(Read(a.Plan.MapKey));
|
||||
a.BeforeFishCount=map.TryGetValue(a.Plan.Fish,out var old)?FishWeightData.Deserialize(old).TotalNum:0;
|
||||
return a;
|
||||
}
|
||||
internal static bool RewardCurrent(FishingRewardAction a) => a!=null && ReferenceEquals(a,rewardAction) &&
|
||||
!a.Cancelled && FishingCurrent(a.Consumption);
|
||||
internal static bool RewardPanelCurrent(FishingRewardAction a,object panel) => RewardCurrent(a) && ReferenceEquals(a.Panel,panel);
|
||||
sealed class RewardSegment : IDisposable
|
||||
{
|
||||
readonly FishingRewardAction action;
|
||||
internal RewardSegment(FishingRewardAction a) { action=a; }
|
||||
public void Dispose()
|
||||
{
|
||||
if(action.Segment==null) return;
|
||||
action.Segment.Dispose(); action.Records.AddRange(action.Segment.Records);
|
||||
Audit("fishing-reward-segment",new{segment=action.SegmentName,writes=action.Segment.Records.Count});
|
||||
action.Segment=null;action.SegmentName=null;
|
||||
}
|
||||
}
|
||||
internal static IDisposable BeginRewardSegment(string name)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || a.Sealed || collecting!=null || a.Segment!=null ||
|
||||
(name!="strike" && name!="ending" && name!="reward") || !a.Segments.Add(name)) throw Stop("Reward segment is not admissible.");
|
||||
if((name=="ending" && !a.Ending) || (name=="reward" && !a.Applying)) throw Stop("Reward segment order is invalid.");
|
||||
a.SegmentName=name; a.Segment=collecting=new WriteBatch(a.Consumption.Owner);
|
||||
return new RewardSegment(a);
|
||||
}
|
||||
internal static void CheckRewardWrite(string key,string value)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(Permit?.AllowsReward!=true || a==null || a.Cancelled || a.Confirmed) return;
|
||||
if(!RewardCurrent(a)) throw Stop("Reward write belongs to a stale action.");
|
||||
if(a.Segment==null)
|
||||
{
|
||||
Audit("fishing-reward-outside-segment",new{key});
|
||||
if(!string.Equals(Read(key),value,StringComparison.Ordinal)) throw Stop("Value changed outside a fishing reward segment: "+key);
|
||||
}
|
||||
else if(key!="NewPlayerDataDic" && key!=a.Plan.MapKey && !rewardTaskKeys.Contains(key))
|
||||
throw Stop("Unexpected fishing reward key: "+key);
|
||||
}
|
||||
internal static void CheckCaughtFishSave(int fish,int count)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || a.SegmentName!="reward" || a.Plan.Fish!=fish ||
|
||||
count!=checked(a.BeforeFishCount+GContext.container.Resolve<PlayerData>().GetMagnification()))
|
||||
throw Stop("Caught fish final save must contain exactly this catch's increment.");
|
||||
}
|
||||
internal static void CheckRewardMainSave()
|
||||
{
|
||||
if(!RewardCurrent(rewardAction) || rewardAction.SegmentName!="reward") throw Stop("Final reward save outside reward segment.");
|
||||
}
|
||||
internal static void RecordRewardGuideNotification()
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(!RewardCurrent(a) || a.SegmentName!="reward" || ++a.GuideNotifications!=1) throw Stop("Duplicate reward guide notification.");
|
||||
CheckRewardGuide(); Audit("fishing-reward-guide",new{notifications=a.GuideNotifications,activated=0});
|
||||
}
|
||||
internal static async Task ConfirmRewardAsync(FishingRewardAction a,PlayerKVEditResult primary)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!RewardCurrent(a) || a.Segment!=null || collecting!=null || a.Sealed || a.Segments.Count!=3 || a.GuideNotifications!=1)
|
||||
throw Stop("Reward records must be complete and sealed once.");
|
||||
a.Sealed=true;
|
||||
var last=a.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal);
|
||||
if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) ||
|
||||
!last.ContainsKey(a.Plan.MapKey) || last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null))
|
||||
throw Stop("Reward final writes require the original map/main and remote tickets.");
|
||||
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
{
|
||||
var owner=a.Consumption.Owner;
|
||||
var flush=await PlayFabMgr.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)
|
||||
{
|
||||
var t=r.Edit.Ticket;if(t==null)continue;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
a.Confirmed=true;
|
||||
Audit("fishing-reward-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash})});
|
||||
}
|
||||
catch(Exception e) { Fault(e.Message);throw; }
|
||||
}
|
||||
internal static void PresentReward(FishingRewardAction a,Action show)
|
||||
{
|
||||
if(!RewardCurrent(a) || !a.Confirmed) throw Stop("Reward cannot be presented before confirmation.");
|
||||
if(a.Presented)return;a.Presented=true;show();Audit("fishing-reward-presented",new{a.Plan.Fish});
|
||||
}
|
||||
internal static void CancelReward(object actor,string reason)
|
||||
{
|
||||
var a=rewardAction;
|
||||
if(a==null || a.Cancelled || (!ReferenceEquals(actor,a.Controller) && !ReferenceEquals(actor,a.Panel) && !ReferenceEquals(actor,a.Consumption.Actor)))return;
|
||||
if(a.Ending && !a.Confirmed && ReferenceEquals(actor,a.Controller))return;
|
||||
a.Cancelled=true;
|
||||
if(Ready && !a.Confirmed) Fault("Fishing reward cancelled: "+reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7e000adddc1b4e5796d6728a1d142813
|
||||
@@ -0,0 +1,557 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UniRx;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
internal static async Task<string> VerifyFishingRewardForEditorAsync()
|
||||
{
|
||||
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready ||
|
||||
Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad || Enabled || handle!=null ||
|
||||
File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
|
||||
throw new InvalidOperationException("Idle editor without a real permit required.");
|
||||
if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running.");
|
||||
var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction);
|
||||
var oldContainer=GContext.container;var oldEvents=FishingContextAccess.Events;
|
||||
var folder=Path.Combine(Root,"reward-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture=null;Task pending=null;Container container=null;PlayerData player=null;
|
||||
RewardSubscribers subscribers=null;
|
||||
var oldLanguages=LocalizationMgr.enumLanguage;
|
||||
var textTable=typeof(LocalizationMgr).GetField("table",BindingFlags.Static|BindingFlags.NonPublic);
|
||||
var oldTextTable=textTable.GetValue(null);
|
||||
GameObject pfShell=null;var pfField=typeof(PlayFabMgr).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic);var oldPF=pfField.GetValue(null);
|
||||
int checks=0,groups=0;var passed=new List<string>();string result=null;
|
||||
var ioDiagnostics=new System.Collections.Concurrent.ConcurrentQueue<string>();
|
||||
EventHandler<System.Runtime.ExceptionServices.FirstChanceExceptionEventArgs> observeIO=(_,e)=>
|
||||
{if(e.Exception is IOException || e.Exception is UnauthorizedAccessException)ioDiagnostics.Enqueue(e.Exception.ToString());};
|
||||
AppDomain.CurrentDomain.FirstChanceException+=observeIO;
|
||||
void Check(bool yes,string reason) {checks++;if(!yes)throw new InvalidOperationException("Reward validation: "+reason);}
|
||||
async Task Start(string name)
|
||||
{
|
||||
subscribers?.Dispose();subscribers=null;
|
||||
player?.StopRecoveryEnergy();player=null;
|
||||
if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}pending=null;}
|
||||
pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell);pfShell=null;
|
||||
FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose();container=null;
|
||||
fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString()));
|
||||
fixture.Remote["Map_5_102"]="{\"10211\":\"0|10|20|20|False\",\"10241\":\"2|20|30|40|False\"}";
|
||||
var owner=await fixture.Open();
|
||||
Enabled=true;Ready=false;Error=null;failed=0;handle=null;collecting=null;
|
||||
Permit=new PlayerKVLoopPermit{Version=3,Operation="fishing-reward-once",PlayerId="101",
|
||||
AllowedKeys=new[]{"NewPlayerDataDic","Map_5_102","BattlePassRecordTask","late"}};
|
||||
RunPath=folder;mainThread=Thread.CurrentThread.ManagedThreadId;mainContext=SynchronizationContext.Current;
|
||||
Attach(_=>fixture.Faults++,fixture.Close);Publish(owner);
|
||||
fishingAction=new FishingAction(new object(),owner){Consuming=true,Confirmed=true,Continued=true};
|
||||
rewardPlan=new FishingRewardPlan{Fish=10211,Area=102};
|
||||
rewardAction=new FishingRewardAction(fishingAction,new object(),rewardPlan);
|
||||
passed.Add(name);
|
||||
}
|
||||
PlayerKVEditResult Batch(bool localFinal=false)
|
||||
{
|
||||
using(BeginRewardSegment("strike")) Write("BattlePassRecordTask","strike",true);
|
||||
rewardAction.Ending=true;using(BeginRewardSegment("ending")){}
|
||||
rewardAction.Applying=true;
|
||||
PlayerKVEditResult primary;
|
||||
using(BeginRewardSegment("reward"))
|
||||
{
|
||||
Write("BattlePassRecordTask","reward-task",true);
|
||||
Write("Map_5_102","fish",!localFinal);
|
||||
Write("NewPlayerDataDic","intermediate",false);
|
||||
primary=Write("NewPlayerDataDic","reward-main",true);
|
||||
}
|
||||
rewardAction.GuideNotifications=1;
|
||||
return primary;
|
||||
}
|
||||
async Task Reject(Task task,string label)
|
||||
{
|
||||
bool rejected=false;try{await task;}catch(InvalidOperationException){rejected=true;}
|
||||
Check(rejected && !Ready && fixture.Faults==1,label);
|
||||
}
|
||||
void RejectSync(Action body,string label)
|
||||
{bool rejected=false;try{body();}catch(InvalidOperationException){rejected=true;}Check(rejected,label);}
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
// Exercise the real async Init path, using isolated services and the actual loaded guide asset.
|
||||
var potionSource=JObject.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,
|
||||
"../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260928T082052Z/101.json"))))["data"].ToObject<Dictionary<string,string>>();
|
||||
var guideAssetHandle=UnityEngine.AddressableAssets.Addressables.LoadAssetAsync<object>("GuidanceConfig");
|
||||
var guideAsset=(GuidanceDefineSetting)guideAssetHandle.WaitForCompletion();
|
||||
var savedGuideGroups=guideAsset.guidanceGroupDefines;
|
||||
var awaitAssembly=typeof(IEnumeratorAwaitExtensions).Assembly;
|
||||
var syncType=awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil",true);
|
||||
var syncField=syncType.GetField("<UnitySynchronizationContext>k__BackingField",BindingFlags.Static|BindingFlags.NonPublic);
|
||||
var threadField=syncType.GetField("<UnityThreadId>k__BackingField",BindingFlags.Static|BindingFlags.NonPublic);
|
||||
var runnerField=awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner",true).GetField("_instance",BindingFlags.Static|BindingFlags.NonPublic);
|
||||
var savedSync=syncField.GetValue(null);var savedThread=threadField.GetValue(null);var savedRunner=runnerField.GetValue(null);
|
||||
syncField.SetValue(null,SynchronizationContext.Current);threadField.SetValue(null,Thread.CurrentThread.ManagedThreadId);
|
||||
try
|
||||
{
|
||||
foreach(var scenario in new[]{"unavailable-real-101","available","query-failure","node-panel-mismatch",
|
||||
"other-group","finished","index-zero","missing-save","out-of-range","preserve-other-pointer"})
|
||||
{
|
||||
await Start("potion-actual-init-"+scenario);
|
||||
rewardPlan=null;rewardAction=null;
|
||||
Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"GuideSaveDataNew"}).ToArray();
|
||||
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();
|
||||
player=new PlayerData(tables,null);player.lv=62;
|
||||
container.RegisterInstance(tables);container.RegisterInstance(player);
|
||||
var eventData=new FishingEventData(tables);
|
||||
if(scenario!="query-failure")container.RegisterInstance(eventData);
|
||||
if(scenario=="available")
|
||||
{
|
||||
var ev=tables.TbFishingEvent.DataList.First(e=>e.Type==4 && e.SubType==5);
|
||||
typeof(cfg.FishingEvent).GetProperty("TimeDefinition").SetValue(ev,new cfg.LimitedTime(
|
||||
ZZTimeHelper.UtcNow().AddHours(-1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture),
|
||||
ZZTimeHelper.UtcNow().AddHours(1).ToString("yyyy/MM/dd HH:mm:ss",System.Globalization.CultureInfo.InvariantCulture)));
|
||||
typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(ev,new List<cfg.EventCondition>{
|
||||
new cfg.EventCondition(cfg.ConditionType.AccountLevel,new List<string>{"1"},"")});
|
||||
Check(eventData.GetEvent(4,5)==ev.ID,"isolated actual query sees available potion event");
|
||||
}
|
||||
else if(scenario!="query-failure")Check(eventData.GetEvent(4,5)==-1,"isolated actual query sees no potion event");
|
||||
var guideState=new GuideDataCenter(tables,player);container.RegisterInstance(guideState);
|
||||
var diagnostics=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(potionSource,diagnostics));
|
||||
Check(diagnostics.TotalErrors==0,"real 101 guide source decoded");
|
||||
var potionGroup=JsonUtility.FromJson<GuidanceGroupDefine>(JsonUtility.ToJson(savedGuideGroups.First(g=>g.groupName==GroupName.EventPotion01)));
|
||||
guideAsset.guidanceGroupDefines=savedGuideGroups.Select(g=>g.groupName==GroupName.EventPotion01?potionGroup:g).ToArray();
|
||||
var key=GroupName.EventPotion01.ToString();var entry=guideState.GuideSaveDataDic[key];
|
||||
Check(entry.guideSubIndex==4 && !entry.isFinish,"real historical potion progress is unfinished at four");
|
||||
if(scenario=="node-panel-mismatch")potionGroup.guidanceDefines[entry.guideSubIndex].panelName="DifferentPanel";
|
||||
if(scenario=="other-group")
|
||||
{
|
||||
guideState.GuideSaveDataDic.Remove(key);key=GroupName.EventPotion02.ToString();
|
||||
entry.groupName=key;guideState.GuideSaveDataDic[key]=entry;
|
||||
guideAsset.guidanceGroupDefines=guideAsset.guidanceGroupDefines.Where(g=>g.groupName!=GroupName.EventPotion02).ToArray();
|
||||
potionGroup.groupName=GroupName.EventPotion02;
|
||||
}
|
||||
if(scenario=="finished")entry.isFinish=true;
|
||||
if(scenario=="index-zero")entry.guideSubIndex=0;
|
||||
if(scenario=="missing-save")guideState.GuideSaveDataDic.Remove(key);
|
||||
if(scenario=="out-of-range")entry.guideSubIndex=potionGroup.guidanceDefines.Length;
|
||||
var otherGroup=savedGuideGroups.First(g=>g.groupName==GroupName.Fish_01);
|
||||
if(scenario=="preserve-other-pointer")
|
||||
{
|
||||
guideState.curGroupDefine=otherGroup;guideState.curDefine=otherGroup.guidanceDefines[0];
|
||||
guideState.curSaveData=guideState.GuideSaveDataDic[otherGroup.groupName.ToString()];
|
||||
}
|
||||
var before=JsonConvert.SerializeObject(guideState.GuideSaveDataDic);
|
||||
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 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;
|
||||
var completion=GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>{if(e.type==cfg.ConditionType.CompleteGuide)completeEvents++;});
|
||||
bool initStarted=false;
|
||||
try
|
||||
{
|
||||
// Asset is preloaded above: Init has no outstanding asset operation when teardown begins.
|
||||
initStarted=true;guideState.Init();
|
||||
var deadline=System.Diagnostics.Stopwatch.StartNew();
|
||||
while(guideState.guidanceGroupDefines==null && deadline.ElapsedMilliseconds<10000)await Task.Delay(10);
|
||||
await Task.Delay(1);
|
||||
Check(guideState.guidanceGroupDefines!=null && initErrors.Count==0,"actual Init completed without asynchronous errors: "+scenario+"; "+string.Join(" | ",initErrors));
|
||||
if(scenario=="unavailable-real-101")
|
||||
{
|
||||
Check(guideState.curDefine==null && guideState.curGroupDefine==null && guideState.curSaveData==null,
|
||||
"unavailable historical potion guide is not activated by actual Init");
|
||||
Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic),"deferred actual Init preserves every guide entry");
|
||||
guideState.AddIndex(true);GContext.Publish(new GuideNextEvent());
|
||||
Check(before==JsonConvert.SerializeObject(guideState.GuideSaveDataDic) && completeEvents==0,
|
||||
"real AddIndex and GuideNext cannot finish deferred potion progress");
|
||||
Check(!handle.ReadLocalSnapshot().Data.ContainsKey("GuideSaveDataNew") && fixture.Sets==0,
|
||||
"deferral and callbacks introduce no guide write or remote request");
|
||||
}
|
||||
else if(scenario=="preserve-other-pointer")
|
||||
Check(ReferenceEquals(guideState.curGroupDefine,otherGroup) && ReferenceEquals(guideState.curDefine,otherGroup.guidanceDefines[0]),"deferral does not clear another active guide");
|
||||
else if(scenario=="available" || scenario=="query-failure" || scenario=="node-panel-mismatch" || scenario=="other-group")
|
||||
Check(ReferenceEquals(guideState.curGroupDefine,potionGroup) && guideState.curDefine!=null,"non-deferred input retains original recovery: "+scenario);
|
||||
else
|
||||
Check(guideState.curGroupDefine==null && guideState.curDefine==null,"original non-resuming boundary retained: "+scenario);
|
||||
if(scenario!="missing-save")Check(ReferenceEquals(guideState.GuideSaveDataDic[key],entry),"saved progress object retained: "+scenario);
|
||||
if(scenario=="query-failure")Check(initWarnings.Any(w=>w.Contains("Potion guide availability")),"query failure is reported and not treated as unavailable");
|
||||
}
|
||||
finally
|
||||
{
|
||||
completion.Dispose();Application.logMessageReceived-=onLog;guideState.Dispose();
|
||||
guideAsset.guidanceGroupDefines=savedGuideGroups;
|
||||
if(initStarted)UnityEngine.AddressableAssets.Addressables.Release(guideAsset);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
guideAsset.guidanceGroupDefines=savedGuideGroups;
|
||||
UnityEngine.AddressableAssets.Addressables.Release(guideAssetHandle);
|
||||
var runner=runnerField.GetValue(null) as MonoBehaviour;
|
||||
if(runner!=null && !ReferenceEquals(runner,savedRunner))UnityEngine.Object.DestroyImmediate(runner.gameObject);
|
||||
runnerField.SetValue(null,savedRunner);syncField.SetValue(null,savedSync);threadField.SetValue(null,savedThread);
|
||||
}
|
||||
await Start("original-tickets-cross-segment-supersession");var primary=Batch();
|
||||
Check(collecting==null && rewardAction.Segment==null && rewardAction.Records.Count==5,"three scopes disposed and all original writes captured");
|
||||
await ConfirmRewardAsync(rewardAction,primary);int shows=0;PresentReward(rewardAction,()=>shows++);PresentReward(rewardAction,()=>shows++);
|
||||
Check(shows==1 && fixture.Remote["Map_5_102"]=="fish" && fixture.Remote["BattlePassRecordTask"]=="reward-task","final values and one presentation");
|
||||
|
||||
await Start("pending-cannot-present");primary=Batch();fixture.HoldSet=true;
|
||||
pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task;
|
||||
Check(!rewardAction.Confirmed && !rewardAction.Presented,"pending is not success");
|
||||
fixture.ReleaseSet.TrySetResult(true);await pending;pending=null;Check(rewardAction.Confirmed,"response confirms original action");
|
||||
|
||||
await Start("localOnly-map-final-rejected");await Reject(ConfirmRewardAsync(rewardAction,Batch(true)),"local map not remote confirmed");
|
||||
await Start("outside-segment-before-final-save-rejected");
|
||||
using(BeginRewardSegment("strike"))Write("BattlePassRecordTask","strike",true);
|
||||
RejectSync(()=>Write("NewPlayerDataDic","external",true),"external changed write denied before later reward can hide it");
|
||||
Check(fixture.Sets==0 && ReadRejected(),"stopped facade and no outgoing request");
|
||||
|
||||
await Start("same-value-external-supersession-still-rejected");primary=Batch();
|
||||
Write("NewPlayerDataDic","reward-main",true);
|
||||
await Reject(ConfirmRewardAsync(rewardAction,primary),"same-value external ticket is not the original final ticket");
|
||||
await Start("unexpected-backed-up-key-before-admission");
|
||||
using(BeginRewardSegment("strike"))RejectSync(()=>Write("late","unrelated",true),"backed-up but unrelated reward key rejected");
|
||||
Check(rewardAction.Records.Count==0 && fixture.Sets==0,"no unrelated admission");
|
||||
|
||||
await Start("lost-response-no-reward-replay");primary=Batch();fixture.LoseResponse=true;
|
||||
await Reject(ConfirmRewardAsync(rewardAction,primary),"unknown result stops reward");Check(fixture.Sets==1 && !rewardAction.Presented,"single request only");
|
||||
await Start("unauthorized-no-reauthentication");primary=Batch();fixture.Unauthorized=true;
|
||||
await Reject(ConfirmRewardAsync(rewardAction,primary),"401 stops reward");Check(fixture.Auth==1 && fixture.Sets==1,"no relogin or replay");
|
||||
await Start("partial-commit-is-not-success");primary=Batch();fixture.PartialCommit=true;fixture.LoseResponse=true;
|
||||
await Reject(ConfirmRewardAsync(rewardAction,primary),"partial result remains failure");Check(fixture.Sets==1 && !rewardAction.Confirmed,"no refund or second write");
|
||||
await Start("cancel-after-consume-before-reward-confirmed");primary=Batch();fixture.HoldSet=true;
|
||||
pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task;
|
||||
CancelFishing(fishingAction,"actual consumed action cancellation");fixture.ReleaseSet.TrySetResult(true);
|
||||
await Reject(pending,"C1 Continued cannot exempt pending reward");pending=null;Check(rewardAction.Cancelled && !rewardAction.Presented,"old callbacks revoked");
|
||||
await Start("confirmed-normal-cancel-no-second-exit");primary=Batch();await ConfirmRewardAsync(rewardAction,primary);
|
||||
CancelReward(rewardAction.Controller,"normal disable");CancelFishing(fishingAction,"normal actor destroy");
|
||||
Check(rewardAction.Cancelled && fishingAction.Cancelled && fixture.Faults==0 && Ready,"confirmed normal disable never faults");
|
||||
await Start("battle-controller-disable-still-cancels");
|
||||
CancelReward(rewardAction.Controller,"controller disabled");
|
||||
Check(rewardAction.Cancelled && !Ready && fixture.Faults==1,"battle controller disable still cancels");
|
||||
await Start("ending-teardown-pre-confirm-not-cancel");primary=Batch();
|
||||
CancelReward(rewardAction.Controller,"controller disabled");CancelReward(rewardAction.Controller,"controller destroyed");
|
||||
Check(!rewardAction.Cancelled && Ready && fixture.Faults==0,"ending teardown window is not a cancellation");
|
||||
await ConfirmRewardAsync(rewardAction,primary);shows=0;PresentReward(rewardAction,()=>shows++);
|
||||
Check(shows==1 && rewardAction.Confirmed && rewardAction.Presented,"reward completes and presents after teardown window");
|
||||
await Start("repeated-segment-and-sealed-action-rejected");
|
||||
using(BeginRewardSegment("strike")){}
|
||||
RejectSync(()=>BeginRewardSegment("strike"),"strike cannot repeat");
|
||||
|
||||
await Start("reward-storage-error-before-send");
|
||||
var file=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single();
|
||||
using(var locked=new FileStream(file,FileMode.Open,FileAccess.Read,FileShare.None))
|
||||
using(BeginRewardSegment("strike"))RejectSync(()=>Write("NewPlayerDataDic","locked",true),"real state-file storage failure");
|
||||
Check(fixture.Sets==0 && !Ready,"storage fault before network");
|
||||
|
||||
foreach(var scenario in new[]{"non-record","new-record","new-fish"})
|
||||
{
|
||||
await Start("real-fish-final-save-"+scenario);
|
||||
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);
|
||||
player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);container.RegisterInstance(player);container.RegisterInstance(fish);
|
||||
var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0});
|
||||
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
|
||||
var data=new Dictionary<int,FishWeightData>{{10241,FishWeightData.Deserialize("2|20|30|40|False")}};
|
||||
if(scenario!="new-fish")data[10211]=FishWeightData.Deserialize("0|10|20|20|False");
|
||||
var fields=BindingFlags.Instance|BindingFlags.NonPublic;
|
||||
typeof(PlayerFishData).GetField("fishData",fields).SetValue(fish,data);
|
||||
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);
|
||||
rewardAction.BeforeFishCount=scenario=="new-fish"?0:20;
|
||||
using(BeginRewardSegment("strike")){}rewardAction.Ending=true;using(BeginRewardSegment("ending")){}
|
||||
rewardAction.Applying=true;
|
||||
var events=new List<cfg.ConditionType>();using(GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>events.Add(e.type)))
|
||||
using(BeginRewardSegment("reward"))
|
||||
{
|
||||
fish.SetGetFishCount(10211);fish.SetWeight(10211,scenario=="new-record"?12:5);
|
||||
fish.SaveCaughtFishForKVLoop(10211);
|
||||
primary=Write("NewPlayerDataDic","fixture-main",true);
|
||||
}
|
||||
rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary);
|
||||
var savedMap=JsonConvert.DeserializeObject<Dictionary<int,string>>(fixture.Remote["Map_5_102"]);
|
||||
var value=FishWeightData.Deserialize(savedMap[10211]);
|
||||
Check(value.TotalNum==(scenario=="new-fish"?10:30),"original count increment exactly once");
|
||||
Check(value.MaxG==(scenario=="new-record"?12:scenario=="new-fish"?5:10),"original maximum weight rule");
|
||||
Check(savedMap[10241]=="2|20|30|40|False" && value.TotalCard==(scenario=="new-fish"?0:20) && value.Level==0,"other fish and card data unchanged");
|
||||
Check(events.Count==2 && events.Contains(cfg.ConditionType.GetFishWeight) && events.Contains(cfg.ConditionType.GetFishID),"final save never republishes fish events");
|
||||
var fishSchema=new PlayerKVSchemaSnapshot("reward-fish",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary<string,int>(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0,
|
||||
fish: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)),
|
||||
cards:tables.TbFishCard.DataMap.ToDictionary(p=>p.Key,p=>new PlayerKVFishCardConfig(p.Key,p.Value.MaxLevel,p.Value.CardsRequiredList,p.Value.WeightMagList)));
|
||||
var fishErrors=new PlayerKVSchemaDiagnostics();
|
||||
var decodedFish=PlayerKVSaveSchema.DecodeFish(new Dictionary<string,string>{{"Map_5_102",fixture.Remote["Map_5_102"]}},new Dictionary<string,string>(),fishSchema,fishErrors);
|
||||
Check(decodedFish!=null && fishErrors.TotalErrors==0 && decodedFish.Maps[102][10211].Number==value.TotalNum && decodedFish.RawMaps[102][10211].Weight==value.MaxG,"real Map schema reads the saved catch");
|
||||
}
|
||||
foreach(var cash in new[]{0,122})
|
||||
{
|
||||
await Start("actual-main-reward-and-subscribers-cash-"+cash);
|
||||
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);
|
||||
LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();textTable.SetValue(null,null);
|
||||
player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);var small=new SmallGameData(tables,null,null);
|
||||
var achievements=new AchievementDataManager(tables);var box=new FishingBoxData();var events=new FishingEventData(tables);
|
||||
container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(small);container.RegisterInstance(achievements);container.RegisterInstance(box);container.RegisterInstance(events);
|
||||
fish.ResetForKVLoad();small.ResetForKVLoad();achievements.ResetForKVLoad();box.ResetForKVLoad();achievements.Init();
|
||||
var core=new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0});
|
||||
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
|
||||
subscribers=new RewardSubscribers();subscribers.Bind(tables,events,container);
|
||||
Permit.AllowedKeys=Permit.AllowedKeys.Concat(rewardTaskKeys).Distinct().ToArray();
|
||||
int countEvents=0;var seen=new List<cfg.ConditionType>();
|
||||
using(GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>{seen.Add(e.type);if(e.type==cfg.ConditionType.GetFishCount)countEvents++;}))
|
||||
{
|
||||
using(BeginRewardSegment("strike"))GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.PerfectStrikeCount,1));
|
||||
rewardAction.Ending=true;using(BeginRewardSegment("ending")){}
|
||||
rewardAction.Applying=true;
|
||||
using(BeginRewardSegment("reward"))
|
||||
{
|
||||
// Actual unchanged data methods; visual/formula extraction is tested separately.
|
||||
player.SaveFishingFrequency();
|
||||
GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.CashFromFishing,cash));
|
||||
GContext.Publish(new ConditionTypeEvent(cfg.ConditionType.MaxCashFromFishing,cash/10));
|
||||
if(cash>0)player.AddGold((ulong)cash);
|
||||
foreach(var condition in new[]{cfg.ConditionType.GetFishWeight,cfg.ConditionType.GetFishID,cfg.ConditionType.GetBoosFish,cfg.ConditionType.GetBiggerThanHugeFishCount})
|
||||
GContext.Publish(new ConditionTypeEvent(condition,2));
|
||||
Write("Map_5_102",Read("Map_5_102"),true);
|
||||
primary=player.SaveFishingRewardForKVLoop();
|
||||
}
|
||||
}
|
||||
rewardAction.GuideNotifications=1;await ConfirmRewardAsync(rewardAction,primary);
|
||||
var modern=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
var coreValues=modern["newData"].Split(',');
|
||||
Check(coreValues[1]==(100+cash).ToString() && coreValues[14]=="14" && coreValues[15]=="112","cash and one catch with original x10 total frequency");
|
||||
Check(coreValues[3]=="393" && coreValues[9]=="112" && countEvents==1,"reward never consumes again or repeats count event");
|
||||
Check(modern["ad"]==achievements.AchievementDataToJson() && achievements.GetAchievementDatas(cfg.ConditionType.GetFishCount)==1,"last primary serializes real achievement subscriber");
|
||||
Check(modern.ContainsKey("ld") && modern["ld"].Split('|').Length==3,"original luck serialization preserved");
|
||||
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Any(k=>tables.TbBattlePassTask.GetOrDefault(k.Key).Type==cfg.ConditionType.PerfectStrikeCount && k.Value==1),"S1 real battle task writes");
|
||||
Check(subscribers.Lucky.DicTasks.All(k=>k.Value==0) && subscribers.Magic.DicTasks.All(k=>k.Value==0) && subscribers.Mini.Data.Exp==0,"actual lucky/magic/mini ignore reward conditions absent from their real config");
|
||||
foreach(var condition in rewardConditions.Where(x=>x!=cfg.ConditionType.ConsumeEnergy))
|
||||
Check(seen.Count(x=>x==condition)==1,"reward condition once: "+condition);
|
||||
Check(fixture.Remote.ContainsKey("BattlePassRecordTask") && !fixture.Remote.ContainsKey("MiniBPKeyNew") && !fixture.Remote.ContainsKey("EventLuckGameRecord") && !fixture.Remote.ContainsKey("EventLuckMagicPlayfabData"),"only applicable original task saves confirmed; no invented task updates");
|
||||
var schema=new PlayerKVSchemaSnapshot("reward-main",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,new Dictionary<string,int>(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24),0);
|
||||
var errors=new PlayerKVSchemaDiagnostics();Check(PlayerKVSaveSchema.DecodeCore(new Dictionary<string,string>(),modern,schema,errors)!=null && errors.TotalErrors==0,"main schema reads final original serializer");
|
||||
}
|
||||
await Start("actual-reentry-and-old-permit-gates");
|
||||
Check(!BeginRewardDrawing(rewardAction.Controller),"duplicate drawing never reaches original battle");
|
||||
rewardAction.Ending=true;Check(!BeginRewardEnding(rewardAction.Controller,true),"duplicate fish end never opens another panel");
|
||||
rewardAction.Applying=true;Check(BindRewardPanel(new object())==null,"second panel never reapplies rewards");
|
||||
Permit.Version=2;Permit.Operation="fishing-consume-once";RejectSync(()=>BeginRewardDrawing(new object()),"v2 cannot draw");
|
||||
await Start("v1-cannot-draw");Permit.Version=1;Permit.Operation=null;RejectSync(()=>BeginRewardDrawing(new object()),"v1 cannot draw");
|
||||
await Start("natural-failure-stops-without-reward");
|
||||
RejectSync(()=>BeginRewardEnding(rewardAction.Controller,false),"natural failure not converted to catch");
|
||||
Check(fixture.Sets==0 && rewardAction.Records.Count==0 && !rewardAction.Applying,"failure has no reward or refund");
|
||||
foreach(bool complete in new[]{false,true})
|
||||
{
|
||||
await Start(complete?"actual-panel-disable-confirmed":"actual-panel-disable-pending");
|
||||
primary=Batch();if(complete)await ConfirmRewardAsync(rewardAction,primary);
|
||||
var shell=new GameObject("KV inactive reward panel lifecycle fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
try
|
||||
{
|
||||
var panel=shell.AddComponent<FishingRewardPanel>();rewardAction.Panel=panel;
|
||||
typeof(FishingRewardPanel).GetMethod("OnDisable",BindingFlags.Instance|BindingFlags.NonPublic).Invoke(panel,null);
|
||||
Check(rewardAction.Cancelled && fixture.Faults==(complete?0:1),"actual panel OnDisable revokes pending only");
|
||||
Check(!RewardPanelCurrent(rewardAction,panel),"old panel continuation is revoked");
|
||||
}
|
||||
finally{UnityEngine.Object.DestroyImmediate(shell);}
|
||||
}
|
||||
await Start("real-backup-no-special-fish-guide-preflight");
|
||||
container=new Container();FishingContextAccess.Use(container);FishingContextAccess.Events=new EventAggregator();
|
||||
var guide=new GuideDataCenter(null,null);container.RegisterInstance(guide);
|
||||
var backup=JsonConvert.DeserializeObject<Dictionary<string,object>>(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests/backups/20260924T084836Z/102.json"))));
|
||||
var raw=JsonConvert.DeserializeObject<Dictionary<string,string>>(backup["data"].ToString());
|
||||
var guideErrors=new PlayerKVSchemaDiagnostics();guide.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,guideErrors));
|
||||
guide.guidanceGroupDefines=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset").guidanceGroupDefines;
|
||||
Check(guideErrors.TotalErrors==0,"real guide backup decodes");
|
||||
var gateTables=new cfg.Tables(n=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData",n+".json")))));
|
||||
container.RegisterInstance(gateTables);
|
||||
rewardPlan.Data=new FishingData{curFishData=gateTables.TbFishData.GetOrDefault(10211)};
|
||||
var guideBefore=JsonConvert.SerializeObject(guide.GuideSaveDataDic);
|
||||
CheckRewardGuide();
|
||||
Check(Ready && fixture.Sets==0 && guideBefore==JsonConvert.SerializeObject(guide.GuideSaveDataDic),"ordinary fish admitted without guide writes or consumption");
|
||||
var originalGroups=guide.guidanceGroupDefines;
|
||||
foreach(var kind in new[]{"ordinary-incomplete","active-node","active-group","last-node","extra-node","missing-node","trigger","parameter","unknown-group","positive-skill","last-skill","escape-skill","active-skill","active-dash","active-dirty"})
|
||||
{
|
||||
// Read-only fault probes share an isolated fixture; no real run is reset.
|
||||
var guideCopy=JsonUtility.FromJson<GuidanceGroupDefine>(JsonUtility.ToJson(originalGroups.First(g=>g.groupName.ToString()=="FishSkill01")));
|
||||
var baseFish=gateTables.TbFishData.GetOrDefault(10211);
|
||||
var fishJson=SimpleJSON.JSON.Parse(baseFish.json.ToString());
|
||||
var testFish=new cfg.FishData(fishJson);
|
||||
var testData=new FishingData{curFishData=testFish};
|
||||
guide.guidanceGroupDefines=originalGroups.Select(g=>g.groupName==guideCopy.groupName?guideCopy:g).ToArray();
|
||||
guide.curDefine=null;guide.curGroupDefine=null;
|
||||
guide.GuideSaveDataDic["Fish_01"].isFinish=true;
|
||||
switch(kind)
|
||||
{
|
||||
case "ordinary-incomplete":guide.GuideSaveDataDic["Fish_01"].isFinish=false;break;
|
||||
case "active-node":guide.curDefine=guideCopy.guidanceDefines[0];break;
|
||||
case "active-group":guide.curGroupDefine=guideCopy;break;
|
||||
case "last-node":guideCopy.guidanceDefines.Last().panelName="FishingRewardPanel";break;
|
||||
case "extra-node":guideCopy.guidanceDefines=guideCopy.guidanceDefines.Concat(new[]{new GuidanceDefine{panelName="FishingPanel_Advanced"}}).ToArray();break;
|
||||
case "missing-node":guideCopy.guidanceDefines=Array.Empty<GuidanceDefine>();break;
|
||||
case "trigger":guideCopy.TriggerConditionsType=GuideTriggerConditions.Next;break;
|
||||
case "parameter":guideCopy.TriggerConditionsParameter="changed";break;
|
||||
case "unknown-group":guideCopy.groupName=(GroupName)999999;break;
|
||||
case "positive-skill":case "last-skill":case "escape-skill":
|
||||
var skillJson=SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString());skillJson["SpSkillID"]=123456789;
|
||||
if(kind=="escape-skill")fishJson["FishEscape"]=skillJson;
|
||||
else {var list=new SimpleJSON.JSONArray();if(kind=="last-skill")list.Add(SimpleJSON.JSON.Parse(baseFish.FishEscape.json.ToString()));list.Add(skillJson);fishJson["FishSKillList"]=list;}
|
||||
testData.curFishData=new cfg.FishData(fishJson);break;
|
||||
case "active-skill":testData.fishSpSkillData=new CorruptSpSkillData();break;
|
||||
case "active-dash":testData.superDashSpSkillData=new SuperDashSpSkillData();break;
|
||||
case "active-dirty":testData.dirtyStuckSpSkillData=new DirtyStuckSpSkillData();break;
|
||||
}
|
||||
// Stop() is idempotent; all cases are read-only gate probes, not fresh consumption attempts.
|
||||
RejectSync(()=>CheckRewardGuide(testData),"guide/skill drift denied: "+kind);
|
||||
Check(fixture.Sets==0,"gate drift never writes: "+kind);
|
||||
}
|
||||
guide.guidanceGroupDefines=originalGroups;guide.curDefine=null;guide.curGroupDefine=null;guide.GuideSaveDataDic["Fish_01"].isFinish=true;
|
||||
|
||||
foreach(var scenario in new[]{"ordinary","zero-cruise","cruise","invalid-cruise","free","retry","fixed","duel","special-item","missing-map","wrong-area","event-field","event-count","trigger-pack","activity-drift","guide-before-S1","guide-before-S2","guide-before-S3"})
|
||||
{
|
||||
await Start("actual-full-reward-preflight-"+scenario);
|
||||
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);
|
||||
player=new PlayerData(tables,null);var fish=new PlayerFishData(tables,null);fish.ResetForKVLoad();
|
||||
var events=new FishingEventData(tables);var guideState=new GuideDataCenter(tables,player);
|
||||
var diag=new PlayerKVSchemaDiagnostics();guideState.ReplaceFromKVDecoded(GuideDataCenter.DecodeForKVLoad(raw,diag));guideState.guidanceGroupDefines=originalGroups;
|
||||
guideState.guidanceDefineSetting=UnityEditor.AssetDatabase.LoadAssetAtPath<GuidanceDefineSetting>("Assets/ABPackage/pkgPrepare/Export/GuidanceConfig.asset");
|
||||
var scopes=new Activity.ActivityScopeManager();var manager=new Activity.ActivityManager(tables,scopes);
|
||||
container.RegisterInstance(player);container.RegisterInstance(fish);container.RegisterInstance(events);container.RegisterInstance(guideState);
|
||||
container.RegisterInstance(new CollectingData());container.RegisterInstance(new PiggyBankPackData());container.RegisterInstance(new BargainPackData());container.RegisterInstance(new FishingChallengeManager());
|
||||
container.RegisterInstance(scopes);container.RegisterInstance(manager);
|
||||
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(new PlayerKVCoreState(new object[]{60,100UL,0,393,5,4,30003,new DateTime(2026,1,1),0,112,7UL,2,102,102,13,102,0,0,0}),new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
|
||||
player.IsOpenMagnification=true;fish.IsOpenPiercing=true;
|
||||
var data=new FishingData{curFishData=tables.TbFishData.GetOrDefault(10211),fishItem=tables.TbItem.DataList.First(x=>x.RedirectID==10211 && x.SubType==1),RodId=30003,MapId=102};
|
||||
rewardPlan=null;rewardAction=null;
|
||||
switch(scenario)
|
||||
{
|
||||
case "zero-cruise":case "cruise":case "invalid-cruise":
|
||||
typeof(PlayerFishData).GetField("fishSwimToMap",BindingFlags.Instance|BindingFlags.NonPublic).SetValue(fish,new Dictionary<int,int>{{102,scenario=="zero-cruise"?0:scenario=="cruise"?10211:-2}});break;
|
||||
case "free":data.IsFree=true;break;case "retry":data.IsRetry=true;break;case "duel":data.IsDuel=true;break;
|
||||
case "fixed":data.fixedFishList=tables.TbFixedFishList.DataList.First();break;
|
||||
case "special-item":data.fishItem=tables.TbItem.DataList.First(x=>x.SubType==11);break;
|
||||
case "missing-map":Permit.AllowedKeys=new[]{"NewPlayerDataDic"};break;
|
||||
case "wrong-area":var fj=SimpleJSON.JSON.Parse(data.curFishData.json.ToString());fj["AreaID"]=999999;data.curFishData=new cfg.FishData(fj);break;
|
||||
case "event-field":var ev=tables.TbFishingEvent.DataList.First(x=>x.ID==10100000);typeof(cfg.FishingEvent).GetProperty("PopupPriority").SetValue(ev,ev.PopupPriority+1);break;
|
||||
case "event-count":tables.TbFishingEvent.DataList.Remove(tables.TbFishingEvent.DataList.First(x=>x.ID==10100000));break;
|
||||
case "trigger-pack":typeof(cfg.TriggerPackManager).GetProperty("Condition").SetValue(tables.TbTriggerPackManager.DataList.First(),cfg.ConditionType.GetFishCount);break;
|
||||
}
|
||||
if(scenario=="ordinary" || scenario=="zero-cruise" || scenario=="activity-drift" || scenario.StartsWith("guide-before-"))
|
||||
{
|
||||
PrepareFishingReward(fishingAction,data);
|
||||
Check(Ready && rewardPlan.Data==data && fixture.Sets==0,"actual full preflight admitted before any consumption");
|
||||
if(scenario=="activity-drift")
|
||||
{scopes.CreateActivityScope((1,1));RejectSync(CheckRewardContext,"actual activity scope drift denied");}
|
||||
else if(scenario.StartsWith("guide-before-"))
|
||||
{
|
||||
var actor=new object();
|
||||
if(scenario!="guide-before-S1") {Check(BeginRewardDrawing(actor),"first drawing");using(BeginRewardSegment("strike")){} }
|
||||
if(scenario=="guide-before-S3") {Check(BeginRewardEnding(actor,true),"first ending");using(BeginRewardSegment("ending")){} }
|
||||
guideState.curDefine=originalGroups.First().guidanceDefines.First();
|
||||
if(scenario=="guide-before-S1")RejectSync(()=>BeginRewardDrawing(actor),"active guide before drawing");
|
||||
else if(scenario=="guide-before-S2")RejectSync(()=>BeginRewardEnding(actor,true),"active guide before ending");
|
||||
else RejectSync(()=>BindRewardPanel(new object()),"active guide before reward data");
|
||||
}
|
||||
else
|
||||
{
|
||||
var actor=new object();Check(BeginRewardDrawing(actor),"actual prepare-draw");using(BeginRewardSegment("strike")){}
|
||||
Check(BeginRewardEnding(actor,true),"actual ending");using(BeginRewardSegment("ending")){}
|
||||
Check(BindRewardPanel(new object())!=null,"actual panel binding with before count");
|
||||
using(GContext.OnEvent<OnEventTriggerGuide>().Subscribe(guideState.TriggerGuideNext))
|
||||
using(BeginRewardSegment("reward")){GContext.Publish(new OnEventTriggerGuide("FishingRewardPanel"));RecordRewardGuideNotification();}
|
||||
Check(rewardAction.GuideNotifications==1 && guideState.curDefine==null && guideState.curGroupDefine==null,"one original guide notification and no activated guide");
|
||||
}
|
||||
}
|
||||
else RejectSync(()=>PrepareFishingReward(fishingAction,data),"full gate rejects "+scenario);
|
||||
Check(fixture.Sets==0,"preflight case no network or consumed energy: "+scenario);
|
||||
}
|
||||
await Start("reward-confirmation-timeout-no-replay");primary=Batch();fixture.HoldSet=true;
|
||||
pending=ConfirmRewardAsync(rewardAction,primary);await fixture.SetEntered.Task;
|
||||
await Reject(pending,"actual 30-second timeout");pending=null;fixture.ReleaseSet.TrySetResult(true);
|
||||
Check(fixture.Sets==1 && !rewardAction.Confirmed && !rewardAction.Presented,"timeout never repeats reward");
|
||||
result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,folder});
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
subscribers?.Dispose();subscribers=null;
|
||||
LocalizationMgr.enumLanguage=oldLanguages;textTable.SetValue(null,oldTextTable);
|
||||
player?.StopRecoveryEnergy();if(fixture!=null)await fixture.Close();if(pending!=null){try{await pending;}catch{}}
|
||||
pfField.SetValue(null,oldPF);if(pfShell!=null)UnityEngine.Object.DestroyImmediate(pfShell);
|
||||
FishingContextAccess.Use(oldContainer);FishingContextAccess.Events=oldEvents;container?.Dispose();
|
||||
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction,rewardPlan,rewardAction)=saved;
|
||||
}
|
||||
catch {validationCleanupFailure=fixture;throw;}
|
||||
finally
|
||||
{
|
||||
AppDomain.CurrentDomain.FirstChanceException-=observeIO;
|
||||
if(Directory.Exists(folder))File.WriteAllText(Path.Combine(folder,"storage-diagnostics.json"),JsonConvert.SerializeObject(ioDiagnostics.ToArray()));
|
||||
Interlocked.Exchange(ref validationRunning,0);
|
||||
}
|
||||
}
|
||||
File.WriteAllText(Path.Combine(folder,"result.json"),result);return result;
|
||||
}
|
||||
sealed class RewardSubscribers:IDisposable
|
||||
{
|
||||
const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic;
|
||||
static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null);
|
||||
GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider;
|
||||
internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky;
|
||||
internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini;
|
||||
internal void Bind(cfg.Tables tables,FishingEventData events,Container container)
|
||||
{
|
||||
Red.SetValue(null,new RedPointManager());
|
||||
shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=shell.AddComponent<PlayFabMgr>();
|
||||
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
|
||||
PF.SetValue(null,pf);
|
||||
var items=new PlayerItemData(tables);container.RegisterInstance(items);
|
||||
var data=new BattlePassData(tables);
|
||||
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);
|
||||
Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle);
|
||||
var common=tables.TbCommonTask.DataList.Where(t=>rewardConditions.Contains(t.TaskType)).Select(t=>t.Id).ToArray();
|
||||
Lucky=new EventLuckyGameModel(events,tables);foreach(var id in common)Lucky.DicTasks.Add(id,0);
|
||||
Magic=new EventLuckMagicModel{DicTasks=common.ToDictionary(x=>x,x=>0),HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()};
|
||||
var rings=(List<EventLuckMagicRing>)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic);
|
||||
for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i));
|
||||
magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask);
|
||||
provider=new MiniBattlePassDataProvider(tables,items);
|
||||
var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0);
|
||||
var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id);
|
||||
var type=(MiniBattlePassType)table.Type;
|
||||
Mini=provider.GetModel(type);
|
||||
provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID;
|
||||
typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity);
|
||||
typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id});
|
||||
// Synthetic fixture has an active window; real-run time is never modified.
|
||||
Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1);
|
||||
typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1));
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose();
|
||||
if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose();
|
||||
PF.SetValue(null,oldPF);Red.SetValue(null,oldRed);
|
||||
if(shell!=null)UnityEngine.Object.DestroyImmediate(shell);
|
||||
}
|
||||
}
|
||||
static bool ReadRejected() {try{Read("NewPlayerDataDic");return false;}catch(InvalidOperationException){return true;}}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8725a7f81e4971046acaa6c14a8f6f97
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,330 @@
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using asap.core;
|
||||
using GameCore;
|
||||
using Newtonsoft.Json;
|
||||
using UniRx;
|
||||
using DG.Tweening;
|
||||
using UnityEngine;
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
// Only a fixed idle-editor test; the handler inherited from EquipmentFixture never forwards requests.
|
||||
internal static async Task<string> VerifyFishingForEditorAsync()
|
||||
{
|
||||
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready ||
|
||||
Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad ||
|
||||
Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
|
||||
throw new InvalidOperationException("Idle editor without a real permit required.");
|
||||
if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation running.");
|
||||
var saved=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction);
|
||||
var oldContainer=GContext.container; var oldEvents=FishingContextAccess.Events;
|
||||
var folder=Path.Combine(Root,"fishing-fixed-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture=null; Task pending=null; Container container=null; PlayerData player=null; IDisposable subscription=null;
|
||||
var oldLanguages=LocalizationMgr.enumLanguage;
|
||||
var textTable=typeof(LocalizationMgr).GetField("table",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
var oldTextTable=textTable.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); }
|
||||
async Task Start(string name)
|
||||
{
|
||||
subscribers?.Dispose(); subscribers=null;
|
||||
player?.StopRecoveryEnergy(); player=null; subscription?.Dispose(); subscription=null;
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pending!=null) { try { await pending; } catch { } pending=null; }
|
||||
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null;
|
||||
fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString()));
|
||||
var owner=await fixture.Open();
|
||||
Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null; fishingAction=null;
|
||||
Permit=new PlayerKVLoopPermit{Version=2,Operation="fishing-consume-once",PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}};
|
||||
RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
Attach(_=>fixture.Faults++,fixture.Close); Publish(owner);
|
||||
passed.Add(name);
|
||||
}
|
||||
(FishingAction action,WriteBatch batch,PlayerKVEditResult primary) Batch(bool duplicates=false)
|
||||
{
|
||||
var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"first consume admitted");
|
||||
var batch=BeginFishingWrites(action);
|
||||
if(duplicates) { Write("NewPlayerDataDic","intermediate-local",false); Write("NewPlayerDataDic","intermediate-remote",true); }
|
||||
Write("k","task",true); var primary=Write("NewPlayerDataDic","final",true); batch.Dispose();
|
||||
return(action,batch,primary);
|
||||
}
|
||||
async Task Reject(Task task,string label)
|
||||
{
|
||||
bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; }
|
||||
Check(rejected && !Ready && fixture.Faults==1,label);
|
||||
}
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
await Start("duplicate-state-writes-and-original-final-tickets");
|
||||
var b=Batch(true); Check(b.batch.Records.Count==4 && !b.batch.Records[0].Remote && b.batch.Records[0].Edit.Ticket==null,"local intermediate captured");
|
||||
await ConfirmFishingAsync(b.action,b.batch,b.primary);
|
||||
Check(fixture.Remote["NewPlayerDataDic"]=="final" && fixture.Remote["k"]=="task","both final values persisted");
|
||||
int continued=0; ContinueFishing(b.action,()=>continued++); ContinueFishing(b.action,()=>continued++);
|
||||
Check(continued==1 && !ClaimFishingConsumption(b.action) && BeginFishing(new object())==null,"continue and consume/cast only once");
|
||||
|
||||
await Start("pending-response-does-not-continue"); b=Batch(); fixture.HoldSet=true;
|
||||
pending=ConfirmFishingAsync(b.action,b.batch,b.primary);
|
||||
Check(ReferenceEquals(await Task.WhenAny(fixture.SetEntered.Task,pending,Task.Delay(5000)),fixture.SetEntered.Task),"Set entered");
|
||||
Check(!b.action.Confirmed && !b.action.Continued,"no continuation during pending response");
|
||||
fixture.ReleaseSet.TrySetResult(true); await pending; pending=null;
|
||||
Check(b.action.Confirmed,"original response confirms");
|
||||
|
||||
await Start("final-localOnly-rejected-before-flush"); b=Batch();
|
||||
// A sealed record ledger from actual writes with a localOnly final key.
|
||||
collecting=b.batch; Write("k","local-tail",false); b.batch.Dispose();
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"local final cannot report remote success"); Check(fixture.Sets==0,"no flush of rejected batch");
|
||||
|
||||
await Start("external-final-supersession"); b=Batch(); Write("NewPlayerDataDic","later-action",true);
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"never substitute later ticket");
|
||||
|
||||
await Start("lost-response-after-commit"); b=Batch(); fixture.LoseResponse=true;
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"Unknown stops run");
|
||||
Check(fixture.Sets==1 && fixture.Remote["NewPlayerDataDic"]=="final","one uncertain commit, no replay");
|
||||
|
||||
await Start("unauthorized-does-not-reauthenticate"); b=Batch(); fixture.Unauthorized=true;
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"401 stops run"); Check(fixture.Auth==1 && fixture.Sets==1,"single auth and Set");
|
||||
|
||||
await Start("cancelled-object-does-not-continue-after-commit"); b=Batch(); fixture.HoldSet=true;
|
||||
pending=ConfirmFishingAsync(b.action,b.batch,b.primary); await fixture.SetEntered.Task;
|
||||
CancelFishing(b.action,"synthetic disable"); fixture.ReleaseSet.TrySetResult(true);
|
||||
await Reject(pending,"disabled original action cannot continue"); pending=null;
|
||||
Check(!b.action.Continued && b.action.Cancelled,"no stale continuation");
|
||||
|
||||
await Start("close-before-confirmation"); b=Batch(); await fixture.Close();
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"closed owner rejected"); Check(fixture.Sets==0,"close does not invent flush");
|
||||
|
||||
await Start("partial-commit-is-not-atomic-success"); b=Batch(); fixture.PartialCommit=true; fixture.LoseResponse=true;
|
||||
await Reject(ConfirmFishingAsync(b.action,b.batch,b.primary),"partial unknown stops run");
|
||||
Check(fixture.Sets==1 && fixture.Remote["k"]=="task" && fixture.Remote["NewPlayerDataDic"]=="original-save","partial commit retained without compensation");
|
||||
|
||||
await Start("storage-error-before-send");
|
||||
var stateFile=Directory.GetFiles(Path.Combine(folder,groups.ToString()),"state.json",SearchOption.AllDirectories).Single();
|
||||
using(var locked=new FileStream(stateFile,FileMode.Open,FileAccess.Read,FileShare.None))
|
||||
{
|
||||
bool storage=false; try { Write("NewPlayerDataDic","locked",true); } catch(InvalidOperationException e) { storage=e.Message.Contains("StorageError"); }
|
||||
Check(storage && !Ready && fixture.Faults==1 && fixture.Sets==0,"storage failure cannot be accepted or retried");
|
||||
}
|
||||
|
||||
foreach(bool complete in new[]{false,true})
|
||||
{
|
||||
await Start(complete?"actual-tween-autoKill-after-complete":"actual-tween-kill-before-complete");
|
||||
var shell=new GameObject("KV inactive fishing tween fixture"); shell.SetActive(false); shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
Tween tween=null;
|
||||
// DOTween.Init deliberately does nothing in EditMode; Kill otherwise returns
|
||||
// before reaching the real tween engine. Scope this fixed fixture gate to this
|
||||
// synchronous block and restore it even when an assertion fails.
|
||||
var initialized=typeof(DOTween).GetField("initialized",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic|System.Reflection.BindingFlags.Public);
|
||||
var wasInitialized=initialized.GetValue(null);
|
||||
try
|
||||
{
|
||||
initialized.SetValue(null,true);
|
||||
var behavior=shell.AddComponent<FishingBehaviorB>();
|
||||
var action=BeginFishing(behavior);
|
||||
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
|
||||
float value=0; tween=DOTween.To(()=>value,v=>value=v,1,100).SetAutoKill(true).SetUpdate(UpdateType.Manual);
|
||||
typeof(FishingBehaviorB).GetMethod("TrackKVCastTween",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,new object[]{tween});
|
||||
Check(tween.onKill!=null && tween.onComplete!=null,"actual callbacks installed");
|
||||
if(complete) tween.Complete(); else tween.Kill(false);
|
||||
Check(action.Cancelled!=complete && !action.Continued,"actual Tween Kill boundary complete="+complete+" cancelled="+action.Cancelled+" enabled="+Enabled+" active="+tween.IsActive());
|
||||
Check(fixture.Faults==(complete?0:1) && fixture.Sets==0,"autoKill is not a cancellation");
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); }
|
||||
finally { initialized.SetValue(null,wasInitialized); }
|
||||
}
|
||||
}
|
||||
|
||||
await Start("completed-action-normal-disable-is-not-failure");
|
||||
var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave;
|
||||
try
|
||||
{
|
||||
var behavior=completedShell.AddComponent<FishingBehaviorB>();
|
||||
var action=BeginFishing(behavior);Check(ClaimFishingConsumption(action),"completed action admitted");
|
||||
var batch=BeginFishingWrites(action);var primary=Write("NewPlayerDataDic","completed",true);batch.Dispose();
|
||||
await ConfirmFishingAsync(action,batch,primary);ContinueFishing(action,()=>{});
|
||||
typeof(FishingBehaviorB).GetField("kvFishingAction",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).SetValue(behavior,action);
|
||||
typeof(FishingBehaviorB).GetMethod("OnDisable",System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic).Invoke(behavior,null);
|
||||
Check(action.Cancelled && action.Continued && Ready && fixture.Faults==0,"normal disable after completed consumption must not Fault");
|
||||
Check(!FishingCurrent(action) && BeginFishing(behavior)==null,"normal disable still revokes callbacks and never restores quota");
|
||||
}
|
||||
finally { UnityEngine.Object.DestroyImmediate(completedShell); }
|
||||
|
||||
await Start("observation-timeout-does-not-replay"); b=Batch(); fixture.HoldSet=true;
|
||||
pending=ConfirmFishingAsync(b.action,b.batch,b.primary);
|
||||
await Reject(pending,"30 second observer deadline stops continuation"); pending=null;
|
||||
Check(fixture.Sets==1 && !b.action.Continued,"timeout leaves one admitted write");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
|
||||
await Start("equipment-permit-cannot-cast"); Permit.Version=1; Permit.Operation=null;
|
||||
bool denied=false; try { BeginFishing(new object()); } catch(InvalidOperationException) { denied=true; }
|
||||
Check(denied && fixture.Sets==0,"old permit cannot consume");
|
||||
|
||||
foreach(var scenario in new[]{"above-limit","cross-limit","below-limit","recovery-during-pending"})
|
||||
{
|
||||
await Start("actual-PlayerData-"+scenario);
|
||||
int initialEnergy=scenario=="above-limit"?400:scenario=="cross-limit"?300:250;
|
||||
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);
|
||||
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);
|
||||
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
|
||||
fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad();
|
||||
achievements.Init();
|
||||
int rod=tables.TbRodData.DataMap.Keys.First(),map=tables.TbMapData.DataMap.Keys.First();
|
||||
var core=new PlayerKVCoreState(new object[]{1,10UL,0,initialEnergy,5,0,rod,new DateTime(2026,1,1),0,7,10UL,2,map,map,0,0,0,0,0});
|
||||
player.ReplaceFromKVDecoded(new PlayerKVPlayerState(core,new Dictionary<string,string>(),new Dictionary<int,int>{{-1,1}},new List<int>(),0,false,"0"));
|
||||
// Fixed backing property only, no caller-controlled reflection or gameplay registration.
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player,300);
|
||||
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player,1);
|
||||
if(scenario=="above-limit")
|
||||
{
|
||||
Permit.AllowedKeys=Permit.AllowedKeys.Concat(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}).ToArray();
|
||||
subscribers=new FishingSubscribers();
|
||||
subscribers.Bind(tables,events,container);
|
||||
Check(!tables.TbFishingEvent.DataList.Any(e=>e.ConditionList.Any(c=>c.Condition==cfg.ConditionType.ConsumeEnergy)) &&
|
||||
!tables.TbTriggerPackManager.DataList.Any(t=>t.Condition==cfg.ConditionType.ConsumeEnergy),"real config excludes async activation and trigger-pack writes");
|
||||
}
|
||||
int consumed=0,notified=0;
|
||||
subscription=GContext.OnEvent<ConditionTypeEvent>().Subscribe(e=>{if(e.type==cfg.ConditionType.ConsumeEnergy){consumed+=e.count; Write("k","task-"+e.count,true);}});
|
||||
using(GContext.OnEvent<PlayerData>().Subscribe(_=>notified++))
|
||||
{
|
||||
var action=BeginFishing(new object()); Check(ClaimFishingConsumption(action),"real action claimed");
|
||||
fixture.HoldSet=scenario=="recovery-during-pending";
|
||||
var consumption=player.ConsumeEnergyForKVLoopAsync(3,action); pending=consumption;
|
||||
if(fixture.HoldSet)
|
||||
{
|
||||
await fixture.SetEntered.Task;
|
||||
Check(!action.Confirmed && !action.Continued,"real player pending cannot continue");
|
||||
// Drive the actual due recovery tick while the original Set is still held.
|
||||
// No replacement serialization, no paused production recovery algorithm.
|
||||
player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed*60);
|
||||
Check((int)player.Energy==initialEnergy-2 && !action.Confirmed,"real recovery can write during pending");
|
||||
fixture.ReleaseSet.TrySetResult(true);
|
||||
}
|
||||
await consumption; pending=null;
|
||||
Check((int)player.Energy==initialEnergy-(fixture.HoldSet?2:3) && player.GetEnergyRM()==10 && consumed==3,"one real deduction and event");
|
||||
Check(notified==(scenario=="cross-limit"?2:fixture.HoldSet?3:1),"original notification count preserved");
|
||||
var modern=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
|
||||
Check(modern["newData"].Split(',')[3]==(initialEnergy-3).ToString() && !modern.ContainsKey("energy"),"one canonical representation");
|
||||
Check(fixture.Remote["k"]=="task-3" && action.Confirmed,"synchronous task write confirmed");
|
||||
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy)==3 && modern["ad"].Contains(":") && modern["ad"]==achievements.AchievementDataToJson(),"actual achievement subscriber captured after event");
|
||||
var savedTime=DateTime.Parse(modern["newData"].Split(',')[7]);
|
||||
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);
|
||||
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");
|
||||
}
|
||||
if(subscribers!=null)
|
||||
{
|
||||
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single()==3 && subscribers.Lucky.DicTasks.Values.Single()==3 && subscribers.Magic.DicTasks.Values.Single()==3,"actual task subscriptions each receive one consume event");
|
||||
Check(subscribers.Mini.Data.Exp==3,"actual mini BP subscription receives the event");
|
||||
Check(new[]{"BattlePassRecordTask","MiniBPKeyNew","EventLuckGameRecord","EventLuckMagicPlayfabData"}.All(fixture.Remote.ContainsKey),"all actual subscriber save methods confirmed in original action");
|
||||
}
|
||||
var parsed=new PlayerKVSchemaSnapshot("fixed-fishing",tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
|
||||
new Dictionary<string,int>(),System.Globalization.CultureInfo.CurrentCulture,new DateTime(2026,9,24,0,0,0,DateTimeKind.Utc),0);
|
||||
var errors=new PlayerKVSchemaDiagnostics();
|
||||
Check(PlayerKVSaveSchema.DecodeCore(new Dictionary<string,string>(),modern,parsed,errors)!=null && errors.TotalErrors==0,"actual serialized core loads without alias conflict");
|
||||
}
|
||||
}
|
||||
result=JsonConvert.SerializeObject(new{success=true,groups,assertions=checks,passed,formalRequests=0,synthetic=true,restored=true,folder});
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
subscribers?.Dispose(); subscribers=null;
|
||||
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); } }
|
||||
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;
|
||||
}
|
||||
catch { validationCleanupFailure=fixture; throw; }
|
||||
finally { Interlocked.Exchange(ref validationRunning,0); }
|
||||
}
|
||||
File.WriteAllText(Path.Combine(folder,"result.json"),result); return result;
|
||||
}
|
||||
// Fixed real subscriber fixture. Reflection targets are compiled constants, never supplied by CLI.
|
||||
sealed class FishingSubscribers:IDisposable
|
||||
{
|
||||
const System.Reflection.BindingFlags Fields=System.Reflection.BindingFlags.Instance|System.Reflection.BindingFlags.NonPublic;
|
||||
static readonly System.Reflection.FieldInfo PF=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
static readonly System.Reflection.FieldInfo Red=typeof(RedPointManager).GetField("instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
|
||||
readonly object oldRed=Red.GetValue(null),oldPF=PF.GetValue(null);
|
||||
GameObject shell; LuckyTaskData magicTask; MiniBattlePassDataProvider provider;
|
||||
internal BattlePassDataProvider Battle; internal EventLuckyGameModel Lucky;
|
||||
internal EventLuckMagicModel Magic; internal MiniBattlePassDataModel Mini;
|
||||
internal void Bind(cfg.Tables tables,FishingEventData events,Container container)
|
||||
{
|
||||
Red.SetValue(null,new RedPointManager());
|
||||
shell=new GameObject("KV inactive subscriber storage fixture");shell.SetActive(false);shell.hideFlags=HideFlags.HideAndDontSave;
|
||||
var pf=shell.AddComponent<PlayFabMgr>();
|
||||
if(!pf.IsUnstartedForKVLoad)throw new InvalidOperationException("No PF lifecycle may start in this fixture.");
|
||||
PF.SetValue(null,pf);
|
||||
var items=new PlayerItemData(tables);container.RegisterInstance(items);
|
||||
var data=new BattlePassData(tables);
|
||||
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);
|
||||
Battle=new BattlePassDataProvider(tables,data,items);container.RegisterInstance(Battle);
|
||||
var common=tables.TbCommonTask.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy).Id;
|
||||
Lucky=new EventLuckyGameModel(events,tables);Lucky.DicTasks.Add(common,0);
|
||||
Magic=new EventLuckMagicModel{DicTasks=new Dictionary<int,int>{{common,0}},HigherRingData=EventLuckMagicHigherRingData.CreateEmpty()};
|
||||
var rings=(List<EventLuckMagicRing>)typeof(EventLuckMagicModel).GetField("_rings",Fields).GetValue(Magic);
|
||||
for(int i=1;i<=3;i++)rings.Add(EventLuckMagicRing.CreateEmpty(1,0,(EEventLuckMagicRingType)i));
|
||||
magicTask=new LuckyTaskData(Magic);typeof(EventLuckMagicModel).GetField("_taskData",Fields).SetValue(Magic,magicTask);
|
||||
provider=new MiniBattlePassDataProvider(tables,items);
|
||||
var table=tables.TbMiniBattlePass.DataList.First(t=>t.TaskType==cfg.ConditionType.ConsumeEnergy && t.Param==0);
|
||||
var activity=tables.TbFishingEvent.DataList.First(t=>t.Type==9 && t.RedirectID==table.Id);
|
||||
var type=(MiniBattlePassType)table.Type;
|
||||
Mini=provider.GetModel(type);
|
||||
provider.GetMiniBattlePassRecord("BP_"+type).Id=activity.ID;
|
||||
typeof(MiniBattlePassDataModel).GetField("fishingEvent",Fields).SetValue(Mini,activity);
|
||||
typeof(MiniBattlePassDataModel).GetMethod("InitData",Fields).Invoke(Mini,new object[]{table.Id});
|
||||
// Synthetic fixture has an active window; real-run time is never modified.
|
||||
Mini.startTime=ZZTimeHelper.UtcNow().AddDays(-1);
|
||||
typeof(MiniBattlePassDataModel).GetProperty("endTime").SetValue(Mini,ZZTimeHelper.UtcNow().AddDays(1));
|
||||
}
|
||||
public void Dispose()
|
||||
{
|
||||
Battle?.Dispose();Lucky?.Dispose();magicTask?.Dispose();
|
||||
if(Mini!=null)(typeof(MiniBattlePassDataModel).GetField("disposables",Fields).GetValue(Mini) as IDisposable)?.Dispose();
|
||||
PF.SetValue(null,oldPF);Red.SetValue(null,oldRed);
|
||||
if(shell!=null)UnityEngine.Object.DestroyImmediate(shell);
|
||||
}
|
||||
}
|
||||
sealed class FishingContextAccess:GContext
|
||||
{
|
||||
protected override Task SetupAsync()=>throw new InvalidOperationException("Test-only context.");
|
||||
internal static void Use(Container value) { container=value; }
|
||||
internal static IEventAggregator Events { get=>eventAggregator; set=>eventAggregator=value; }
|
||||
}
|
||||
sealed class FishingNotifications:ILocalNotificationService
|
||||
{
|
||||
public Task Init()=>Task.CompletedTask; public bool IsNoticeOpen=>false;
|
||||
public void OpenSettings(){} public void ScheduleOneTimeNotification(int id,DateTime time,string title,string text){}
|
||||
public void ScheduleDailyNotification(int id,DateTime time,string title,string text){}
|
||||
public void ClearAllNotification(){} public void ClearNotification(int id){} public void ScheduleDefaultNotification(){}
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f0d0cfb141639484a8e9592320fd607a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
621
Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs
Normal file
621
Client/Assets/Scripts/Services/UserService.KVGameplayLoop.cs
Normal file
@@ -0,0 +1,621 @@
|
||||
#if UNITY_EDITOR
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
#if !SERVICE_HOST
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using asap.core;
|
||||
using GameCore;
|
||||
using game.PlayerDataStorage;
|
||||
using UnityEngine;
|
||||
#endif
|
||||
|
||||
namespace game.PlayerDataStorage
|
||||
{
|
||||
// Editor-only permission to use an existing, backed-up disposable save. Never a migration tool.
|
||||
internal sealed class PlayerKVLoopPermit
|
||||
{
|
||||
public int Version { get; set; }
|
||||
public bool Enabled { get; set; }
|
||||
public string PlayerId { get; set; }
|
||||
public string Api { get; set; }
|
||||
public string BackupPath { get; set; }
|
||||
public string BackupSha256 { get; set; }
|
||||
public string[] AllowedKeys { get; set; }
|
||||
[JsonProperty(NullValueHandling=NullValueHandling.Ignore)]
|
||||
public string Operation { get; set; }
|
||||
[JsonIgnore] internal bool AllowsFishing => (Version==2 && Operation=="fishing-consume-once") || AllowsReward;
|
||||
[JsonIgnore] internal bool AllowsReward => Version==3 && Operation=="fishing-reward-once";
|
||||
internal static string Hash(byte[] bytes)
|
||||
{ using (var sha=SHA256.Create()) return BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-", ""); }
|
||||
internal static PlayerKVLoopPermit Read(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var document=JObject.Parse(File.ReadAllText(path));
|
||||
var p=document.ToObject<PlayerKVLoopPermit>();
|
||||
if(p==null || (p.Version!=1 && p.Version!=2 && p.Version!=3) ||
|
||||
(p.Version==1 && document.Property("Operation",StringComparison.Ordinal)!=null) ||
|
||||
(p.Version==2 && p.Operation!="equipment" && p.Operation!="fishing-consume-once") ||
|
||||
(p.Version==3 && p.Operation!="fishing-reward-once") ||
|
||||
!p.Enabled || (p.PlayerId!="101" && p.PlayerId!="102") ||
|
||||
p.Api!="http://s101.local:18080/" || p.AllowedKeys==null || p.AllowedKeys.Length==0 ||
|
||||
p.AllowedKeys.Distinct(StringComparer.Ordinal).Count()!=p.AllowedKeys.Length ||
|
||||
!p.AllowedKeys.Contains("NewPlayerDataDic")) throw new InvalidOperationException("Invalid loop permission.");
|
||||
var root=Path.GetFullPath(Path.GetDirectoryName(path))+Path.DirectorySeparatorChar;
|
||||
var backup=Path.GetFullPath(p.BackupPath);
|
||||
if(!backup.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup must be inside loop workspace.");
|
||||
var bytes=File.ReadAllBytes(backup);
|
||||
if(!string.Equals(Hash(bytes),p.BackupSha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Backup hash mismatch.");
|
||||
var saved=JObject.Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'));
|
||||
var data=saved["data"] as JObject;
|
||||
if((string)saved["playerId"]!=p.PlayerId || (string)saved["api"]!=p.Api || data==null ||
|
||||
p.AllowedKeys.Any(k=>string.IsNullOrEmpty(k) || k.Length>128 || data.Property(k,StringComparison.Ordinal)==null) ||
|
||||
data.Properties().Any(v=>v.Value.Type!=JTokenType.String)) throw new InvalidOperationException("Backup identity or key permission mismatch.");
|
||||
return p;
|
||||
}
|
||||
catch(InvalidOperationException) { throw; }
|
||||
catch(Exception e) { throw new InvalidOperationException("Cannot validate loop permission.",e); }
|
||||
}
|
||||
internal static string Claim(string root,string processIdentity)
|
||||
{
|
||||
if(string.IsNullOrEmpty(processIdentity) || processIdentity.Any(c=>!char.IsLetterOrDigit(c) && c!='-' && c!='_'))
|
||||
throw new InvalidOperationException("Invalid process identity.");
|
||||
var folder=Path.Combine(root,"run-control"); Directory.CreateDirectory(folder);
|
||||
var path=Path.Combine(folder,processIdentity+".claimed");
|
||||
using(var file=new FileStream(path,FileMode.CreateNew,FileAccess.Write,FileShare.Read))
|
||||
{ var bytes=Encoding.UTF8.GetBytes(DateTime.UtcNow.ToString("O")); file.Write(bytes,0,bytes.Length); file.Flush(true); }
|
||||
return path;
|
||||
}
|
||||
}
|
||||
|
||||
#if !SERVICE_HOST
|
||||
internal static partial class PlayerKVGameplayLoop
|
||||
{
|
||||
internal static bool Enabled { get; private set; }
|
||||
internal static bool Ready { get; private set; }
|
||||
internal static string Error { get; private set; }
|
||||
internal static PlayerKVLoopPermit Permit { get; private set; }
|
||||
internal static string Root => Path.GetFullPath(Path.Combine(Application.dataPath,"../../CodexOutput/upserver/KVGameplayLoopTests"));
|
||||
internal static string RunPath { get; private set; }
|
||||
internal static string CacheRoot => Path.Combine(RunPath,"cache");
|
||||
static PlayerKVGameSessionHandle handle;
|
||||
static Action<string> onFault;
|
||||
static Func<Task> onClose;
|
||||
static WriteBatch collecting;
|
||||
static int mainThread, failed;
|
||||
static SynchronizationContext mainContext;
|
||||
static readonly object auditGate=new object();
|
||||
|
||||
internal static void BeforeBootstrap()
|
||||
{
|
||||
var path=Path.Combine(Root,"permit.json");
|
||||
if(!File.Exists(path)) return;
|
||||
// A malformed or active permit must never silently fall back to PF.
|
||||
Enabled=true;
|
||||
if(JObject.Parse(File.ReadAllText(path))["Enabled"]?.Value<bool>()==false) { Enabled=false; return; }
|
||||
if(PlayFabMgr.HasLiveInstanceForKVLoad) throw new InvalidOperationException("Cannot switch a running storage backend.");
|
||||
Permit=PlayerKVLoopPermit.Read(path);
|
||||
using(var process=System.Diagnostics.Process.GetCurrentProcess())
|
||||
{
|
||||
var identity=process.Id+"_"+process.StartTime.ToUniversalTime().Ticks;
|
||||
PlayerKVLoopPermit.Claim(Root,identity);
|
||||
RunPath=Path.Combine(Root,"runs",identity); Directory.CreateDirectory(RunPath);
|
||||
}
|
||||
mainThread=Thread.CurrentThread.ManagedThreadId;
|
||||
mainContext=SynchronizationContext.Current ?? throw new InvalidOperationException("Unity main context required.");
|
||||
GContext.container.Resolve<IConfig>().Set("SaveBackend","kv-loop");
|
||||
GContext.container.Resolve<IConfig>().Set("CustomID",Permit.PlayerId);
|
||||
Audit("claim",new{Permit.PlayerId,Permit.BackupSha256});
|
||||
}
|
||||
internal static void Attach(Action<string> fault,Func<Task> close) { onFault=fault; onClose=close; }
|
||||
internal static void Publish(PlayerKVGameSessionHandle value)
|
||||
{
|
||||
if(!Enabled || failed!=0 || handle!=null || value==null || value.PlayerId!=Permit.PlayerId)
|
||||
throw new InvalidOperationException("Loop handle cannot be published.");
|
||||
handle=value; Ready=true; Audit("ready",new{value.PlayerId,value.OwnerInstanceId,value.Epoch});
|
||||
}
|
||||
internal static PlayerKVGameSessionHandle Capture()
|
||||
{
|
||||
if(!Ready || failed!=0 || handle==null || Thread.CurrentThread.ManagedThreadId!=mainThread)
|
||||
throw Stop("Loop not writable on this thread or requires process restart.");
|
||||
return handle;
|
||||
}
|
||||
internal static string Read(string key)
|
||||
{
|
||||
var result=PlayFabMgr.ReadKV(Capture(),key);
|
||||
if(!result.Success) throw Stop("Read: "+result.Error);
|
||||
return result.Found==true ? result.Value : null;
|
||||
}
|
||||
internal static PlayerKVEditResult Write(string key,string value,bool remote)
|
||||
{
|
||||
var owner=Capture();
|
||||
if(!Permit.AllowedKeys.Contains(key,StringComparer.Ordinal)) throw Stop("Write outside backed-up permission: "+key);
|
||||
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);
|
||||
if(!result.Accepted) throw Stop("Write: "+result.StateResult.Error+" uncertain="+result.StateResult.LocalCommitMayHaveSucceeded);
|
||||
if(collecting!=null)
|
||||
{
|
||||
collecting.Edits.Add(result);
|
||||
collecting.Records.Add(new WriteRecord(key,remote,result,valueHash));
|
||||
}
|
||||
Audit("accepted",new{key,remote,result.StateResult.AttemptSequence,result.StateResult.Revision});
|
||||
return result;
|
||||
}
|
||||
internal sealed class WriteBatch : IDisposable
|
||||
{
|
||||
internal readonly PlayerKVGameSessionHandle Owner;
|
||||
internal readonly List<PlayerKVEditResult> Edits=new List<PlayerKVEditResult>();
|
||||
internal readonly List<WriteRecord> Records=new List<WriteRecord>();
|
||||
internal WriteBatch(PlayerKVGameSessionHandle owner) { Owner=owner; }
|
||||
public void Dispose() { if(ReferenceEquals(collecting,this)) collecting=null; }
|
||||
}
|
||||
internal static WriteBatch BeginEquip()
|
||||
{
|
||||
if(Permit?.AllowsFishing==true) throw Stop("Equipment is outside this fishing permit.");
|
||||
if(collecting!=null) throw Stop("Reentrant equipment action.");
|
||||
return collecting=new WriteBatch(Capture());
|
||||
}
|
||||
internal sealed class WriteRecord
|
||||
{
|
||||
internal readonly string Key,ValueHash;
|
||||
internal readonly bool Remote;
|
||||
internal readonly PlayerKVEditResult Edit;
|
||||
internal WriteRecord(string key,bool remote,PlayerKVEditResult edit,string valueHash) { Key=key; Remote=remote; Edit=edit; ValueHash=valueHash; }
|
||||
}
|
||||
internal sealed class FishingAction
|
||||
{
|
||||
internal readonly object Actor;
|
||||
internal readonly PlayerKVGameSessionHandle Owner;
|
||||
internal bool Consuming,WritesStarted,Cancelled,Confirmed,Continued;
|
||||
internal FishingAction(object actor,PlayerKVGameSessionHandle owner) { Actor=actor; Owner=owner; }
|
||||
}
|
||||
static FishingAction fishingAction;
|
||||
internal static FishingAction BeginFishing(object actor)
|
||||
{
|
||||
if(Permit?.AllowsFishing!=true) throw Stop("A fishing-consume-once permit is required before casting.");
|
||||
if(fishingAction!=null) { Audit("duplicate-suppressed",new{point="cast"}); return null; }
|
||||
return fishingAction=new FishingAction(actor,Capture());
|
||||
}
|
||||
internal static bool FishingCurrent(FishingAction action) => action!=null && ReferenceEquals(action,fishingAction) &&
|
||||
!action.Cancelled && Ready && failed==0 && ReferenceEquals(action.Owner,handle);
|
||||
internal static void CancelFishing(FishingAction action,string reason)
|
||||
{
|
||||
if(action==null || action.Cancelled) return;
|
||||
CancelReward(action.Actor,reason);
|
||||
action.Cancelled=true;
|
||||
if(Ready && !action.Continued) Fault("Fishing action cancelled: "+reason);
|
||||
}
|
||||
internal static bool ClaimFishingConsumption(FishingAction action)
|
||||
{
|
||||
if(!FishingCurrent(action)) throw Stop("Fishing action is no longer current.");
|
||||
if(action.Consuming) { Audit("duplicate-suppressed",new{point="consume"}); return false; }
|
||||
action.Consuming=true; return true;
|
||||
}
|
||||
internal static WriteBatch BeginFishingWrites(FishingAction action)
|
||||
{
|
||||
if(!FishingCurrent(action) || !action.Consuming || action.WritesStarted || collecting!=null || !ReferenceEquals(Capture(),action.Owner))
|
||||
throw Stop("Fishing write batch is not admissible.");
|
||||
action.WritesStarted=true;
|
||||
return collecting=new WriteBatch(action.Owner);
|
||||
}
|
||||
internal static async Task ConfirmFishingAsync(FishingAction action,WriteBatch batch,PlayerKVEditResult primary)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(!FishingCurrent(action) || !ReferenceEquals(batch.Owner,action.Owner) || ReferenceEquals(collecting,batch))
|
||||
throw Stop("Fishing batch must be sealed on its original action.");
|
||||
var last=batch.Records.GroupBy(r=>r.Key,StringComparer.Ordinal).ToDictionary(g=>g.Key,g=>g.Last(),StringComparer.Ordinal);
|
||||
if(!last.TryGetValue("NewPlayerDataDic",out var main) || !ReferenceEquals(main.Edit,primary) ||
|
||||
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);
|
||||
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)
|
||||
{
|
||||
var ticket=record.Edit.Ticket;
|
||||
if(ticket==null) continue; // Intermediate localOnly is not a remote acknowledgement.
|
||||
var final=last[record.Key].Edit.Ticket;
|
||||
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);
|
||||
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);
|
||||
}
|
||||
}
|
||||
action.Confirmed=true;
|
||||
Audit("fishing-consume-confirmed",new{tickets=last.Values.Select(r=>new{key=r.Key,sequence=r.Edit.Ticket.Sequence,valueHash=r.ValueHash}),batch.Owner.OwnerInstanceId,batch.Owner.Epoch});
|
||||
}
|
||||
catch(Exception e) { Fault(e.Message); throw; }
|
||||
}
|
||||
internal static void ContinueFishing(FishingAction action,Action continuation)
|
||||
{
|
||||
if(!FishingCurrent(action) || !action.Confirmed) throw Stop("Fishing cannot continue before confirmation.");
|
||||
if(action.Continued) { Audit("duplicate-suppressed",new{point="continue"}); return; }
|
||||
action.Continued=true;
|
||||
continuation();
|
||||
Audit("fishing-consume-ready",new{action.Owner.OwnerInstanceId,action.Owner.Epoch});
|
||||
}
|
||||
internal static async Task ConfirmEquipAsync(WriteBatch batch,PlayerKVEditResult primary)
|
||||
{
|
||||
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);
|
||||
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);
|
||||
if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status);
|
||||
}
|
||||
if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed.");
|
||||
Audit("equipment-confirmed",new{tickets=batch.Edits.Count(e=>e.Ticket!=null)});
|
||||
}
|
||||
internal static async Task FlushObservedAsync()
|
||||
{
|
||||
if(!Ready) return;
|
||||
try
|
||||
{
|
||||
var result=await PlayFabMgr.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); }
|
||||
}
|
||||
internal static void Revoke() { Ready=false; }
|
||||
internal static Task CloseAsync() { Revoke(); return onClose?.Invoke() ?? Task.CompletedTask; }
|
||||
internal static InvalidOperationException Stop(string message) { Fault(message); return new InvalidOperationException(message); }
|
||||
internal static void Fault(string message)
|
||||
{
|
||||
Ready=false;
|
||||
if(Interlocked.Exchange(ref failed,1)!=0) return;
|
||||
Error=message;
|
||||
try { Audit("fault",new{message}); } catch(Exception e) { Debug.LogException(e); }
|
||||
Debug.LogError("KV loop stopped: "+message);
|
||||
if(Thread.CurrentThread.ManagedThreadId==mainThread) onFault?.Invoke(message);
|
||||
else mainContext?.Post(_=>onFault?.Invoke(message),null);
|
||||
}
|
||||
internal static void Audit(string kind,object detail)
|
||||
{
|
||||
if(RunPath==null) return;
|
||||
lock(auditGate) File.AppendAllText(Path.Combine(RunPath,"events.jsonl"),JsonConvert.SerializeObject(new{at=DateTime.UtcNow,kind,detail})+Environment.NewLine);
|
||||
}
|
||||
|
||||
// Fixed, compiled Editor test. No caller-supplied URL, code, reflection target, or real run permit.
|
||||
static int validationRunning;
|
||||
static object validationCleanupFailure;
|
||||
internal static async Task<string> VerifyEquipmentForEditorAsync()
|
||||
{
|
||||
if(UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready ||
|
||||
UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length!=0 || PlayFabMgr.HasLiveInstanceForKVLoad ||
|
||||
Enabled || handle!=null || File.Exists(Path.Combine(Root,"permit.json")) || validationCleanupFailure!=null)
|
||||
throw new InvalidOperationException("An idle editor without a real loop permit is required.");
|
||||
if(Interlocked.CompareExchange(ref validationRunning,1,0)!=0) throw new InvalidOperationException("Validation already running.");
|
||||
var original=(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext);
|
||||
var folder=Path.Combine(Root,"fixed-equipment-runs",DateTime.UtcNow.ToString("yyyyMMddTHHmmss")+"_"+Guid.NewGuid().ToString("N"));
|
||||
EquipmentFixture fixture=null;
|
||||
Task pendingConfirmation=null;
|
||||
string result=null;
|
||||
int assertions=0, groups=0;
|
||||
void Check(bool ok,string reason) { assertions++; if(!ok) throw new InvalidOperationException("Equipment validation: "+reason); }
|
||||
async Task Start()
|
||||
{
|
||||
if(fixture!=null) await fixture.Close();
|
||||
fixture=new EquipmentFixture(Path.Combine(folder,groups.ToString()));
|
||||
var owner=await fixture.Open();
|
||||
Enabled=true; Ready=false; Error=null; failed=0; handle=null; collecting=null;
|
||||
Permit=new PlayerKVLoopPermit{PlayerId="101",AllowedKeys=new[]{"k","NewPlayerDataDic","late"}};
|
||||
RunPath=folder; mainThread=Thread.CurrentThread.ManagedThreadId; mainContext=SynchronizationContext.Current;
|
||||
Attach(_=>fixture.Faults++,fixture.Close); Publish(owner);
|
||||
groups++;
|
||||
}
|
||||
async Task Reject(Task task,string contains)
|
||||
{
|
||||
bool rejected=false;
|
||||
try { await task; } catch(InvalidOperationException e) { rejected=e.Message.Contains(contains); }
|
||||
Check(rejected,"expected rejection: "+contains);
|
||||
Check(!Ready && fixture.Faults==1,"one fault revokes facade");
|
||||
}
|
||||
try
|
||||
{
|
||||
if(!PlayerKVGameLoadGuard.TryEnter(out var probe)) throw new InvalidOperationException("Load guard occupied.");
|
||||
probe.Dispose(); Directory.CreateDirectory(folder);
|
||||
await Start();
|
||||
Check(Read("missing")==null,"valid missing key remains null");
|
||||
var batch=BeginEquip();
|
||||
// This nested callback represents the synchronous Publish subscriber in the equipment chain.
|
||||
Action nested=()=>Write("k","nested",true);
|
||||
nested(); var primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose();
|
||||
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");
|
||||
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");
|
||||
|
||||
await Start(); batch=BeginEquip();
|
||||
Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose();
|
||||
fixture.HoldSet=true;
|
||||
var confirmation=ConfirmEquipAsync(batch,primary);
|
||||
pendingConfirmation=confirmation;
|
||||
var entered=await Task.WhenAny(fixture.SetEntered.Task,confirmation,Task.Delay(5000));
|
||||
Check(ReferenceEquals(entered,fixture.SetEntered.Task),"first set entered");
|
||||
var late=Write("late","later-write",true);
|
||||
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 Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose();
|
||||
fixture.Unauthorized=true;
|
||||
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
|
||||
Check(fixture.Auth==1 && fixture.Sets==1,"401 never reauthenticates or retries");
|
||||
|
||||
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","lost-response",true); batch.Dispose();
|
||||
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");
|
||||
|
||||
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose();
|
||||
await fixture.Close();
|
||||
await Reject(ConfirmEquipAsync(batch,primary),"Equipment flush");
|
||||
Check(fixture.Sets==0,"close does not flush pending writes");
|
||||
|
||||
await Start(); bool denied=false;
|
||||
try { Write("unbacked-key","x",true); } catch(InvalidOperationException e) { denied=e.Message.Contains("permission"); }
|
||||
Check(denied && fixture.Sets==0 && !Ready,"key permission checked before writes");
|
||||
await Start(); await fixture.Close(); denied=false;
|
||||
try { Read("k"); } catch(InvalidOperationException) { denied=true; }
|
||||
Check(denied && !Ready,"stale read cannot become missing");
|
||||
|
||||
var originalContainer=GContext.container;
|
||||
var container=new Container();
|
||||
try
|
||||
{
|
||||
var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData"));
|
||||
var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json"))));
|
||||
container.RegisterInstance<cfg.Tables>(tables);
|
||||
EquipmentContextAccess.Use(container);
|
||||
using(container.InjectionScope()) container.Register<EventShootingRangeData>().AsSingleton();
|
||||
var shooting=container.Resolve<EventShootingRangeData>();
|
||||
Check(shooting!=null && ReferenceEquals(shooting,container.Resolve<EventShootingRangeData>()),"M24 container resolves one instance");
|
||||
shooting.ResetForKVLoad();
|
||||
Check(!shooting.KVLoadPresent,"M24 can enter the existing load transaction without activation");
|
||||
}
|
||||
finally { EquipmentContextAccess.Use(originalContainer); container.Dispose(); }
|
||||
Check(ReferenceEquals(GContext.container,originalContainer),"M24 global container restored");
|
||||
groups++;
|
||||
result=JsonConvert.SerializeObject(new{success=true,groups,assertions,formalRequests=0,synthetic=true,restored=true,folder});
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if(fixture!=null) await fixture.Close();
|
||||
if(pendingConfirmation!=null)
|
||||
try { await pendingConfirmation; } catch(Exception e) { Debug.LogWarning("Observed failed synthetic confirmation during cleanup: "+e.GetType().Name); }
|
||||
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext)=original;
|
||||
}
|
||||
catch { validationCleanupFailure=fixture; throw; }
|
||||
finally { Interlocked.Exchange(ref validationRunning,0); }
|
||||
}
|
||||
File.WriteAllText(Path.Combine(folder,"result.json"),result);
|
||||
return result;
|
||||
}
|
||||
|
||||
sealed class EquipmentContextAccess:GContext
|
||||
{
|
||||
protected override Task SetupAsync() => throw new InvalidOperationException("Not a gameplay context.");
|
||||
internal static void Use(Container value) { container=value; }
|
||||
}
|
||||
internal static string InspectPFBackupForEditor(string path)
|
||||
{
|
||||
if(Enabled || validationRunning!=0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready ||
|
||||
UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length!=0)
|
||||
throw new InvalidOperationException("Idle editor required for source inspection.");
|
||||
path=Path.GetFullPath(path);
|
||||
var allowed=Path.GetFullPath(Path.Combine(Root,"pf-source-backups"))+Path.DirectorySeparatorChar;
|
||||
if(!path.StartsWith(allowed,StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("Expected local PF source backup.");
|
||||
var bytes=File.ReadAllBytes(path); var source=JObject.Parse(Encoding.UTF8.GetString(bytes));
|
||||
var id=(string)source["customId"];
|
||||
if((string)source["source"]!="PlayFab" || (string)source["titleId"]!="108EE8" || (id!="101" && id!="102"))
|
||||
throw new InvalidOperationException("Source identity mismatch.");
|
||||
var raw=source["data"].ToObject<Dictionary<string,string>>();
|
||||
var originalContainer=GContext.container; var isolated=new Container();
|
||||
try
|
||||
{
|
||||
var configPath=Path.GetFullPath(Path.Combine(Application.dataPath,"../../cfgData"));
|
||||
var tables=new cfg.Tables(name=>SimpleJSON.JSON.Parse(File.ReadAllText(Path.Combine(configPath,name+".json"))));
|
||||
isolated.RegisterInstance<cfg.Tables>(tables); EquipmentContextAccess.Use(isolated);
|
||||
var now=DateTime.UtcNow;
|
||||
var snapshot=PlayerKVGameplayBindings.Snapshot(now);
|
||||
// Diagnostic-only context: this is NOT a real KV Prepare/Open and cannot publish a handle.
|
||||
var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,id,1,ownerId:"offline-PF-candidate",data:raw);
|
||||
var account=new PlayerKVAccountDescriptor(id,1,0,false,DateTime.SpecifyKind(DateTime.MinValue,DateTimeKind.Utc),now);
|
||||
var identity=new PlayerKVIdentitySnapshot((string)source["playFabId"],false,PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
|
||||
var decoded=PlayerKVSaveSchema.Decode(prepared,account,id,1,true,snapshot,snapshot.Version,identity);
|
||||
return JsonConvert.SerializeObject(new{sourceOnly=true,syntheticDescriptor=true,usesLocalUtc=true,customId=id,path,sha256=PlayerKVLoopPermit.Hash(bytes),keys=raw.Count,
|
||||
verdict=decoded.Verdict.ToString(),errors=decoded.Diagnostics.TotalErrors,
|
||||
details=decoded.Diagnostics.Details.Select(d=>new{module=d.Module,field=d.Field,code=d.Code.ToString()}),
|
||||
configuredRods=decoded.Data?.Module<PlayerKVFishState>(3).RodLevels.Keys.ToArray(),
|
||||
gameplayPublished=false,writes=0});
|
||||
}
|
||||
finally { EquipmentContextAccess.Use(originalContainer); isolated.Dispose(); }
|
||||
}
|
||||
sealed class EquipmentFixture
|
||||
{
|
||||
static readonly Uri Api=new Uri("http://kv-loop.invalid/");
|
||||
readonly string cache;
|
||||
readonly game.Account.S101AccountSession session;
|
||||
readonly PlayerKVGameSessionBridge bridge;
|
||||
internal readonly Dictionary<string,string> Remote=new Dictionary<string,string>{{"k","original"},{"NewPlayerDataDic","original-save"}};
|
||||
internal readonly TaskCompletionSource<bool> SetEntered=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal readonly TaskCompletionSource<bool> ReleaseSet=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
internal int Sets,Auth,Faults;
|
||||
internal bool Unauthorized,LoseResponse,HoldSet,PartialCommit;
|
||||
sealed class Handler:HttpMessageHandler
|
||||
{
|
||||
readonly Func<HttpRequestMessage,Task<HttpResponseMessage>> send;
|
||||
internal Handler(Func<HttpRequestMessage,Task<HttpResponseMessage>> send) { this.send=send; }
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request,CancellationToken token) => send(request);
|
||||
}
|
||||
static HttpResponseMessage Json(object body) => new HttpResponseMessage(HttpStatusCode.OK){Content=new StringContent(JsonConvert.SerializeObject(body))};
|
||||
internal EquipmentFixture(string cache)
|
||||
{
|
||||
this.cache=cache;
|
||||
session=new game.Account.S101AccountSession(Api,jar=>new Handler(request=>
|
||||
{
|
||||
if(request.RequestUri!=new Uri(Api,"account/Login")) throw new InvalidOperationException("Unexpected synthetic login target.");
|
||||
Auth++; jar.Add(Api,new Cookie("sid","101"));
|
||||
return Task.FromResult(Json(new{state=0,internalId=101,newCreate=false,createTime="2026-01-01T00:00:00Z",svrTime="2026-01-01T00:00:00Z"}));
|
||||
}));
|
||||
bridge=new PlayerKVGameSessionBridge(jar=>new Handler(async request=>
|
||||
{
|
||||
if(request.RequestUri.Host!=Api.Host || jar.GetCookies(Api)["sid"]?.Value!="101") throw new InvalidOperationException("Unexpected synthetic KV identity.");
|
||||
if(request.RequestUri.AbsolutePath=="/playerdata/Get") return Json(new{data=Remote});
|
||||
if(request.RequestUri.AbsolutePath!="/playerdata/Set") throw new InvalidOperationException("Unexpected synthetic operation.");
|
||||
Sets++; SetEntered.TrySetResult(true);
|
||||
if(HoldSet) await ReleaseSet.Task;
|
||||
if(Unauthorized) return new HttpResponseMessage(HttpStatusCode.Unauthorized);
|
||||
var body=JObject.Parse(await request.Content.ReadAsStringAsync());
|
||||
var values=((JObject)body["data"]).Properties();
|
||||
foreach(var pair in PartialCommit?values.Take(1):values) Remote[pair.Name]=(string)pair.Value;
|
||||
if(LoseResponse) throw new HttpRequestException("Synthetic response lost after commit.");
|
||||
return Json(new{});
|
||||
}));
|
||||
}
|
||||
internal async Task<PlayerKVGameSessionHandle> Open()
|
||||
{
|
||||
if(!(await session.LoginAsync("101")).Success) throw new InvalidOperationException("Synthetic login failed.");
|
||||
var opened=await bridge.OpenAsync(session,cache,"101",Api,1,e=>e==1);
|
||||
if(!opened.Success) throw new InvalidOperationException("Synthetic open failed: "+opened.Error);
|
||||
return opened.Handle;
|
||||
}
|
||||
internal async Task Close()
|
||||
{
|
||||
ReleaseSet.TrySetResult(true);
|
||||
await bridge.CloseAsync();
|
||||
session.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !SERVICE_HOST
|
||||
namespace game
|
||||
{
|
||||
public partial class PlayFabUserService
|
||||
{
|
||||
PlayerKVGameSessionBridge loopBridge;
|
||||
PlayerKVGameLoadGuard loopGuard;
|
||||
Task loopClosing;
|
||||
TaskCompletionSource<bool> loopSettled;
|
||||
bool loopAttempted, loopStopping;
|
||||
internal bool KVLoopLoading { get; private set; }
|
||||
|
||||
async Task<ELoginStep> LoginKVGameplayLoopAsync(Action<int> progress,int generation)
|
||||
{
|
||||
if(loopAttempted || loopStopping || CustomId!=PlayerKVGameplayLoop.Permit.PlayerId) return ELoginStep.Api;
|
||||
loopAttempted=true; loopSettled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
loopBridge=new PlayerKVGameSessionBridge();
|
||||
PlayerKVGameplayLoop.Attach(message=>
|
||||
{
|
||||
_=StopKVGameplayLoopAsync();
|
||||
var main=UnityEngine.Application.isPlaying;
|
||||
if(main) _=ExitKVGameplayLoopAsync();
|
||||
},StopKVGameplayLoopAsync);
|
||||
try
|
||||
{
|
||||
if(customServerMgr.ApiBaseUri.AbsoluteUri!=PlayerKVGameplayLoop.Permit.Api)
|
||||
throw new InvalidOperationException("KV loop requires the fixed s101 API.");
|
||||
if(!await LoginPlayFab(generation) || generation!=logoutGeneration) throw new InvalidOperationException("PF identity login failed/stale.");
|
||||
if(!await LoginApiServer() || generation!=logoutGeneration) throw new InvalidOperationException("API login failed/stale.");
|
||||
if(InternalId!=0 || IsLogin) throw new InvalidOperationException("Gameplay identity published before load.");
|
||||
var opened=await loopBridge.OpenAsync(apiSession,PlayerKVGameplayLoop.CacheRoot,CustomId,customServerMgr.ApiBaseUri,
|
||||
generation,e=>!loopStopping && e==logoutGeneration,LoginLifetime);
|
||||
if(!opened.Success) throw new InvalidOperationException("KV open: "+opened.Error);
|
||||
var read=opened.Handle.ReadLocalSnapshot();
|
||||
if(!read.Success) throw new InvalidOperationException("KV snapshot: "+read.Error);
|
||||
var prepared=new PlayerKVLoginPrepareResult(PlayerKVLoginPrepareError.None,apiSession.PlayerId,generation,
|
||||
syncStatus:opened.Handle.Status,ownerId:opened.Handle.OwnerInstanceId,data:read.Data);
|
||||
var account=new PlayerKVAccountDescriptor(apiSession.PlayerId,generation,apiSession.InternalId,
|
||||
apiSession.NewlyCreated,apiSession.CreateTimeUtc,apiSession.ServerTimeUtc);
|
||||
var snapshot=PlayerKVGameplayBindings.Snapshot(apiSession.ServerTimeUtc);
|
||||
var identity=new PlayerKVIdentitySnapshot(UserId,config.Get<bool>("NewlyCreated",false),PlayerKVIdentityChannel.none,Array.Empty<PlayerKVIdentityChannel>());
|
||||
var decoded=PlayerKVSaveSchema.Decode(prepared,account,CustomId,generation,!loopStopping && generation==logoutGeneration,snapshot,snapshot.Version,identity);
|
||||
if(decoded.Data==null) throw new InvalidOperationException("KV schema: "+decoded.Verdict);
|
||||
if(!PlayerKVGameLoadGuard.TryEnter(out loopGuard)) throw new InvalidOperationException("Load guard occupied.");
|
||||
KVLoopLoading=true;
|
||||
var transaction=PlayerKVGameplayBindings.Bind(this);
|
||||
var applied=transaction.Apply(decoded.Data,()=>!loopStopping && generation==logoutGeneration,
|
||||
()=>loopGuard.IsCurrent && loopGuard.Violations==0,()=>{});
|
||||
if(applied!=PlayerKVApplyError.None) throw new InvalidOperationException("KV apply: "+applied);
|
||||
KVLoopLoading=false; loopGuard.Dispose(); loopGuard=null;
|
||||
PlayerKVGameplayLoop.Publish(opened.Handle);
|
||||
PlayFabMgr.Instance.StartKVGameplayLoopSync();
|
||||
InternalId=apiSession.InternalId;
|
||||
GContext.Publish(new EvtAPISvrLoginSuccess{playerId=apiSession.PlayerId,internalId=InternalId});
|
||||
OnInitData?.Invoke(decoded.Data.Raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
|
||||
SetMMTestValue();
|
||||
if(!PlayerKVGameplayLoop.Ready || loopStopping || generation!=logoutGeneration) throw new InvalidOperationException("Initialization revoked loop.");
|
||||
progress?.Invoke(2); return ELoginStep.Done;
|
||||
}
|
||||
catch(Exception e) { PlayerKVGameplayLoop.Fault(e.Message); return ELoginStep.Api; }
|
||||
finally { KVLoopLoading=false; loopSettled.TrySetResult(true); }
|
||||
}
|
||||
async Task ExitKVGameplayLoopAsync()
|
||||
{
|
||||
try { await GContext.container.Resolve<IStageService>().SwitchStageAsync("QuitStage"); }
|
||||
catch(Exception e) { Debug.LogException(e); }
|
||||
}
|
||||
internal Task StopKVGameplayLoopAsync()
|
||||
{
|
||||
if(loopClosing!=null) return loopClosing;
|
||||
loopStopping=true; PlayerKVGameplayLoop.Revoke(); logoutGeneration++; apiLoginGeneration++; InternalId=0;
|
||||
PlayFabMgr.Instance?.StopKVGameplayLoopSync();
|
||||
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
loopClosing=completion.Task; PlayerKVShutdownRegistry.Process.Register(loopClosing);
|
||||
try { loginLifetime.Cancel(); } catch(Exception e) { Debug.LogException(e); }
|
||||
var drain=loopBridge?.CloseAsync() ?? Task.CompletedTask;
|
||||
_=FinishKVGameplayLoopAsync(drain,completion); return loopClosing;
|
||||
}
|
||||
async Task FinishKVGameplayLoopAsync(Task drain,TaskCompletionSource<bool> completion)
|
||||
{
|
||||
try
|
||||
{
|
||||
if(loopSettled!=null) await loopSettled.Task;
|
||||
await drain;
|
||||
if(loopBridge!=null) await loopBridge.CloseAsync();
|
||||
apiSession?.Dispose(); apiSession=null; customServerMgr.ClearAccountSession();
|
||||
loopGuard?.Dispose(); loopGuard=null;
|
||||
UserId=null; DisplayName=null; AvatarUrl=null; InternalId=0;
|
||||
PlayFab.PlayFabClientAPI.ForgetAllCredentials();
|
||||
PlayerKVGameplayLoop.Audit("closed",new{requiresProcessRestart=true}); completion.TrySetResult(true);
|
||||
}
|
||||
catch(Exception e) { completion.TrySetException(e); Debug.LogException(e); }
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5209b70ed0644051842744ab39fdf2f4
|
||||
110
Client/Assets/Scripts/Services/UserService.KVLoad.cs
Normal file
110
Client/Assets/Scripts/Services/UserService.KVLoad.cs
Normal file
@@ -0,0 +1,110 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using game.PlayerDataStorage;
|
||||
|
||||
namespace game
|
||||
{
|
||||
public partial class PlayFabUserService
|
||||
{
|
||||
readonly bool kvControlledInstance;
|
||||
PlayerKVGameLoadCoordinator kvCoordinator;
|
||||
Action<IDictionary<string,string>> kvReceiver;
|
||||
int kvReceiverUsers;
|
||||
internal PlayerKVGameLoadError KVLastError { get; private set; }
|
||||
internal string CaptureSaveBackend() => config==null ? "playfab" : config.Get<string>("SaveBackend","playfab");
|
||||
internal bool RejectSaveBackend(string backend)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(PlayerKVGameplayLoop.Enabled && backend=="kv-loop" && !kvControlledInstance)
|
||||
{ KVLastError=PlayerKVGameLoadError.None; return false; }
|
||||
#endif
|
||||
KVLastError=backend=="kv" ? PlayerKVGameLoadError.BackendNotEnabled : backend!="playfab" ? PlayerKVGameLoadError.BackendUnsupported :
|
||||
kvControlledInstance ? PlayerKVGameLoadError.Input : PlayerKVGameLoadError.None;
|
||||
return KVLastError!=PlayerKVGameLoadError.None;
|
||||
}
|
||||
internal void ConfigureKVForValidation(PlayerKVGameLoadTransaction transaction,PlayFabMgr pf,CustomServerMgr server,Func<bool> unstarted)
|
||||
{
|
||||
if(!kvControlledInstance || kvCoordinator!=null || pf==null || !pf.IsUnstartedForKVLoad || PlayFabMgr.HasLiveInstanceForKVLoad ||
|
||||
server==null || !server.IsFreshForKVLoad || unstarted==null || !unstarted()) throw new InvalidOperationException("Fresh controlled services required.");
|
||||
kvCoordinator=new PlayerKVGameLoadCoordinator(transaction,()=>pf.IsUnstartedForKVLoad && InternalId==0 && unstarted(),
|
||||
NotifyKVLoaded,()=>KVIntents?.Count ?? 0,server.BindKVForValidation,server.ClearKVForValidation);
|
||||
server.KVUnauthorized+=version=> { if(version==server.SessionVersion) { InternalId=0; _=kvCoordinator.Revoke(); } };
|
||||
}
|
||||
internal virtual async Task<PlayerKVGameLoadResult> LoadKVForValidationAsync(PlayerKVGameLoadCapability capability)
|
||||
{
|
||||
if(!kvControlledInstance || kvCoordinator==null || capability==null)
|
||||
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0);
|
||||
try
|
||||
{
|
||||
int loginMs=config==null ? 10000 : PlayerKVSaveSchema.Int32(config.Get<string>("KVLoginCloseObserveMs","10000"));
|
||||
int quitMs=config==null ? 5000 : PlayerKVSaveSchema.Int32(config.Get<string>("KVQuitCloseObserveMs","5000"));
|
||||
if(loginMs!=capability.LoginObserveMs || quitMs!=capability.QuitObserveMs || loginMs<1000 || loginMs>15000 || quitMs<1000 || quitMs>15000)
|
||||
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0);
|
||||
}
|
||||
catch { return new PlayerKVGameLoadResult(PlayerKVGameLoadError.Input,0); }
|
||||
var requestedId=CustomId; var generation=logoutGeneration;
|
||||
var result=await kvCoordinator.LoadAsync(requestedId,capability,LoginLifetime,()=>requestedId==CustomId && generation==logoutGeneration);
|
||||
if(generation!=logoutGeneration || requestedId!=CustomId)
|
||||
{
|
||||
_=kvCoordinator.Revoke();
|
||||
if(result.State==PlayerKVGameLoadState.LoadedForValidation) result=new PlayerKVGameLoadResult(PlayerKVGameLoadError.Stale,result.Epoch);
|
||||
}
|
||||
KVLastError=result.Error; return result;
|
||||
}
|
||||
internal Task CloseKVForValidationAsync() => kvCoordinator?.Revoke() ?? Task.CompletedTask;
|
||||
internal Task<PlayerKVShutdownStatus> ObserveKVQuitAsync() => kvCoordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
|
||||
internal bool KVLoadedForValidation => kvCoordinator?.LoadedForValidation ?? false;
|
||||
internal int KVReceiverCountForValidation => kvReceiverUsers;
|
||||
internal void EnterKVReceiver(Action<IDictionary<string,string>> receiver)
|
||||
{
|
||||
if(!kvControlledInstance || receiver==null || (kvReceiver!=null && kvReceiver!=receiver)) throw new InvalidOperationException("Controlled receiver required.");
|
||||
if(kvReceiverUsers++==0) { kvReceiver=receiver; OnInitData+=receiver; }
|
||||
}
|
||||
internal void ExitKVReceiver()
|
||||
{ if(--kvReceiverUsers==0) { OnInitData-=kvReceiver; kvReceiver=null; } }
|
||||
void NotifyKVLoaded()
|
||||
{
|
||||
var listeners=OnInitData?.GetInvocationList();
|
||||
if(listeners!=null && (listeners.Length!=1 || kvReceiver==null || !listeners[0].Equals(kvReceiver)))
|
||||
throw new InvalidOperationException("Unregistered controlled receiver.");
|
||||
OnInitData?.Invoke(KVLoadingCopy.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal));
|
||||
}
|
||||
internal int KVTapJoyBalance { get; private set; }
|
||||
internal PlayerKVInitializationIntents KVIntents { get; private set; }
|
||||
internal IReadOnlyDictionary<string,string> KVLoadingCopy { get; private set; }
|
||||
internal int MMTestForKVValidation => MMTestValue;
|
||||
internal bool IsControlledKVInstance => kvControlledInstance;
|
||||
|
||||
internal virtual void ResetForKVLoad()
|
||||
{
|
||||
bool admitted=kvControlledInstance, sessionAllowed=apiSession==null;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
admitted=admitted || KVLoopLoading;
|
||||
sessionAllowed=sessionAllowed || KVLoopLoading;
|
||||
#endif
|
||||
if(!admitted || InternalId!=0 || !sessionAllowed)
|
||||
throw new InvalidOperationException("Unstarted controlled identity required.");
|
||||
KVIntents?.Discard(); KVIntents=null; KVLoadingCopy=null;
|
||||
MMTestValue=0; EAccoutUrl=new Dictionary<string,string>(StringComparer.Ordinal); KVTapJoyBalance=0;
|
||||
}
|
||||
internal void ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
|
||||
{
|
||||
bool admitted=kvControlledInstance;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
admitted=admitted || KVLoopLoading;
|
||||
#endif
|
||||
if(!admitted || state==null || raw==null) throw new InvalidOperationException("Controlled login state required.");
|
||||
var copy=raw.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
var intents=new PlayerKVInitializationIntents(epoch,state.Channel);
|
||||
foreach(var pair in state.Normalizations) { copy[pair.Key]=pair.Value; intents.Add(epoch,pair.Key,pair.Value); }
|
||||
KVIntents?.Discard(); KVIntents=intents;
|
||||
KVLoadingCopy=new System.Collections.ObjectModel.ReadOnlyDictionary<string,string>(copy);
|
||||
MMTestValue=state.MMTest; KVTapJoyBalance=state.TapJoyBalance;
|
||||
EAccoutUrl=state.AccountUrls.ToDictionary(p=>p.Key,p=>p.Value,StringComparer.Ordinal);
|
||||
ReplaceKVLinked(state);
|
||||
}
|
||||
internal virtual void ReplaceKVLinked(PlayerKVLoginState state) { }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a676add461b14c90b0f8a6e420884fd6
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Threading.Tasks;
|
||||
using game.Account;
|
||||
using asap.core;
|
||||
using PlayFab;
|
||||
using asap.playfab.async;
|
||||
@@ -46,12 +47,13 @@ namespace game
|
||||
public uint internalId { get; set; }
|
||||
}
|
||||
|
||||
public class EvtAPISvrUnauthorized { }
|
||||
public class EvtAPISvrUnauthorized { public int SessionVersion { get; set; } }
|
||||
|
||||
public interface IUserService
|
||||
{
|
||||
Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true);
|
||||
void Logout();
|
||||
System.Threading.CancellationToken LoginLifetime { get; }
|
||||
bool IsLogin { get; }
|
||||
bool IsAuthorized { get; }
|
||||
DateTime LastLoginTime { get; }
|
||||
@@ -64,6 +66,7 @@ namespace game
|
||||
EntityKey entityKey { get; }
|
||||
Task<string> UpdateAvatarUrl(string url);
|
||||
string UserId { get; }
|
||||
string ApiPlayerId { get; }
|
||||
string CustomId { get; }
|
||||
uint InternalId { get; }
|
||||
string ContinentCode { get; }
|
||||
@@ -96,7 +99,7 @@ namespace game
|
||||
void UpdateProfileLv();
|
||||
}
|
||||
|
||||
public class PlayFabUserService : IUserService
|
||||
public partial class PlayFabUserService : IUserService
|
||||
{
|
||||
[Inject]
|
||||
public IConfig config { get; set; }
|
||||
@@ -104,9 +107,12 @@ namespace game
|
||||
[Inject]
|
||||
public ICustomServerMgr customServerMgr { get; set; }
|
||||
|
||||
public bool IsLogin => PlayFabClientAPI.IsClientLoggedIn();
|
||||
public bool IsLogin => !kvControlledInstance && PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized;
|
||||
|
||||
public bool IsAuthorized => InternalId != 0;
|
||||
public bool IsAuthorized => InternalId != 0 && apiSession != null && apiSession.IsAuthenticated;
|
||||
|
||||
// UserId remains the legacy storage identity until the KV migration.
|
||||
public string ApiPlayerId => IsAuthorized ? apiSession.PlayerId : null;
|
||||
|
||||
public DateTime LastLoginTime { get; protected set; }
|
||||
|
||||
@@ -140,13 +146,51 @@ namespace game
|
||||
return $"Angler#{id[0..5]}";
|
||||
}
|
||||
|
||||
public PlayFabUserService()
|
||||
public PlayFabUserService() : this(null) { }
|
||||
|
||||
internal PlayFabUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability)
|
||||
{
|
||||
GContext.OnEvent<EvtAPISvrUnauthorized>().Subscribe(_ => LoginApiServer());
|
||||
kvControlledInstance = capability != null;
|
||||
if (kvControlledInstance) return;
|
||||
GContext.OnEvent<EvtAPISvrUnauthorized>().Subscribe(evt =>
|
||||
{
|
||||
if (evt.SessionVersion != customServerMgr.SessionVersion) return;
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault("API unauthorized; restart required."); return; }
|
||||
#endif
|
||||
InternalId = 0;
|
||||
if (PlayFabClientAPI.IsClientLoggedIn()) _ = LoginApiServer();
|
||||
});
|
||||
}
|
||||
|
||||
public virtual async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
|
||||
protected int logoutGeneration;
|
||||
private System.Threading.CancellationTokenSource loginLifetime = new System.Threading.CancellationTokenSource();
|
||||
public System.Threading.CancellationToken LoginLifetime => loginLifetime.Token;
|
||||
private readonly System.Threading.SemaphoreSlim loginGate = new System.Threading.SemaphoreSlim(1, 1);
|
||||
public virtual Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true) => LoginWithBackend(progress,CaptureSaveBackend());
|
||||
|
||||
protected async Task<ELoginStep> LoginWithBackend(Action<int> progress,string backend)
|
||||
{
|
||||
var generation = logoutGeneration;
|
||||
var requestedId = CustomId;
|
||||
if(RejectSaveBackend(backend)) return ELoginStep.Api;
|
||||
await loginGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
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(); }
|
||||
}
|
||||
|
||||
private async Task<ELoginStep> LoginCore(Action<int> progress, int generation)
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return await LoginKVGameplayLoopAsync(progress,generation);
|
||||
#endif
|
||||
if (IsLogin)
|
||||
{
|
||||
progress?.Invoke(2);
|
||||
@@ -155,16 +199,19 @@ namespace game
|
||||
|
||||
if (!PlayFabClientAPI.IsClientLoggedIn())
|
||||
{
|
||||
if (!await LoginPlayFab())
|
||||
if (!await LoginPlayFab(generation))
|
||||
return ELoginStep.PlayFab;
|
||||
}
|
||||
|
||||
if (generation != logoutGeneration) return ELoginStep.Api;
|
||||
|
||||
SetMMTestValue();
|
||||
|
||||
GContext.container.Resolve<IChatService>().WaitingForLogin();
|
||||
|
||||
OnYinDunLogin();
|
||||
LoginApiServer();
|
||||
if (!await LoginApiServer() || generation != logoutGeneration)
|
||||
return ELoginStep.Api;
|
||||
|
||||
InitTapJoy();
|
||||
progress?.Invoke(2);
|
||||
@@ -286,7 +333,7 @@ namespace game
|
||||
GEvent.SetUserProp("MMTestValue", ab, true);
|
||||
Debug.Log($"UserId:{UserId} MMTestValue :0b{Convert.ToString(MMTestValue, 2)} MMTestValueString:{ab}");
|
||||
}
|
||||
private async Task<bool> LoginPlayFab()
|
||||
private async Task<bool> LoginPlayFab(int generation)
|
||||
{
|
||||
PlayFabSettings.TitleId = config.Get<string>(GConstant.K_PlayFab_Title, GConstant.V_Debug_PF_Title);
|
||||
PlayFab.Internal.PlayFabWebRequest.SkipCertificateValidation();
|
||||
@@ -303,7 +350,11 @@ namespace game
|
||||
var info_request_parameters = new GetPlayerCombinedInfoRequestParams
|
||||
{
|
||||
GetUserAccountInfo = true,
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
GetUserData = !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled,
|
||||
#else
|
||||
GetUserData = true,
|
||||
#endif
|
||||
GetPlayerProfile = true,
|
||||
ProfileConstraints = new PlayerProfileViewConstraints()
|
||||
{
|
||||
@@ -371,6 +422,11 @@ namespace game
|
||||
#endif
|
||||
}
|
||||
sw.Stop();
|
||||
if (generation != logoutGeneration)
|
||||
{
|
||||
PlayFabClientAPI.ForgetAllCredentials();
|
||||
return false;
|
||||
}
|
||||
if (loginResult == null) return false;
|
||||
|
||||
Debug.Log($"LoginPlayFab elapsed={sw.Elapsed.TotalSeconds}");
|
||||
@@ -401,6 +457,7 @@ namespace game
|
||||
}
|
||||
};
|
||||
var data = await PlayFabClientAsyncAPI.GetPlayerProfileAsync(getPlayerProfileRequest);
|
||||
if (generation != logoutGeneration) return false;
|
||||
//DisplayName = data.PlayerProfile.DisplayName;
|
||||
//AvatarUrl = data.PlayerProfile.AvatarUrl;
|
||||
ContinentCode = data?.PlayerProfile.Locations[0]?.ContinentCode?.ToString() ?? "UNKNOW";
|
||||
@@ -416,7 +473,9 @@ namespace game
|
||||
if (string.IsNullOrEmpty(DisplayName))
|
||||
{
|
||||
sw.Start();
|
||||
DisplayName = await ChangeDisplayName(NewPlayerName);
|
||||
var newDisplayName = await ChangeDisplayName(NewPlayerName);
|
||||
if (generation != logoutGeneration) return false;
|
||||
DisplayName = newDisplayName;
|
||||
sw.Stop();
|
||||
Debug.Log($"LoginPlayFab ChangeDisplayName elapsed={sw.Elapsed.TotalSeconds}");
|
||||
sw.Reset();
|
||||
@@ -427,6 +486,7 @@ namespace game
|
||||
sw.Start();
|
||||
/*AvatarUrl = */
|
||||
await UpdateAvatarUrl(NewPlayerAvatar);
|
||||
if (generation != logoutGeneration) return false;
|
||||
sw.Stop();
|
||||
Debug.Log($"LoginPlayFab update avatar elapsed={sw.Elapsed.TotalSeconds}");
|
||||
sw.Reset();
|
||||
@@ -434,6 +494,7 @@ namespace game
|
||||
//BuglyAgent.SetUserId(UserId);
|
||||
var setting = GContext.container.Resolve<ISettingService>();
|
||||
await UpdateLanguage(setting.Lang);
|
||||
if (generation != logoutGeneration) return false;
|
||||
|
||||
var playfabUserData = loginResult.InfoResultPayload?.UserData;
|
||||
|
||||
@@ -456,69 +517,71 @@ namespace game
|
||||
|
||||
//InitData should invoke after local time adjusted.
|
||||
//save login time to local storage.
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
|
||||
#endif
|
||||
InitData(playfabUserData, UserId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
private bool tryLoginApiSvr = false;
|
||||
private S101AccountSession apiSession;
|
||||
private Task<bool> apiLoginTask;
|
||||
private int apiLoginGeneration;
|
||||
|
||||
private async void LoginApiServer()
|
||||
private Task<bool> LoginApiServer()
|
||||
{
|
||||
if (tryLoginApiSvr)
|
||||
return;
|
||||
tryLoginApiSvr = true;
|
||||
|
||||
var sw = new System.Diagnostics.Stopwatch();
|
||||
|
||||
PlayerProfile playerProfile = new PlayerProfile()
|
||||
{
|
||||
Avatar = AvatarUrl,
|
||||
DisplayName = DisplayName,
|
||||
Level = GContext.container.Resolve<PlayerData>().lv
|
||||
};
|
||||
|
||||
LoginRequest loginRequest = new LoginRequest()
|
||||
{
|
||||
playerId = UserId,
|
||||
playerProfile = playerProfile
|
||||
};
|
||||
if (apiLoginTask != null && !apiLoginTask.IsCompleted)
|
||||
return apiLoginTask;
|
||||
apiLoginTask = LoginApiServerCore();
|
||||
return apiLoginTask;
|
||||
}
|
||||
|
||||
private async Task<bool> LoginApiServerCore()
|
||||
{
|
||||
var generation = ++apiLoginGeneration;
|
||||
var requestedId = CustomId;
|
||||
InternalId = 0;
|
||||
apiSession?.Dispose();
|
||||
apiSession = null;
|
||||
try
|
||||
{
|
||||
var apiLoginResponse = await customServerMgr.AccountRequest("Login", loginRequest);
|
||||
sw.Stop();
|
||||
Debug.Log($"LoginApiServer elapsed={sw.Elapsed.TotalSeconds}");
|
||||
|
||||
if (string.IsNullOrEmpty(apiLoginResponse))
|
||||
customServerMgr.ClearAccountSession();
|
||||
var session = new S101AccountSession(customServerMgr.ApiBaseUri);
|
||||
apiSession = session;
|
||||
var result = await session.LoginAsync(requestedId);
|
||||
if (generation != apiLoginGeneration || requestedId != CustomId)
|
||||
return false;
|
||||
if (!result.Success)
|
||||
{
|
||||
tryLoginApiSvr = false;
|
||||
return;
|
||||
Debug.LogWarning($"API login failed: {result.Error}, HTTP {result.StatusCode}");
|
||||
return false;
|
||||
}
|
||||
|
||||
var result = JObject.Parse(apiLoginResponse);
|
||||
if ((byte)result["state"] == 0)
|
||||
customServerMgr.UseAccountSession(session);
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return true; // Identity is published only after all KV modules load.
|
||||
#endif
|
||||
InternalId = session.InternalId;
|
||||
GContext.Publish(new EvtAPISvrLoginSuccess
|
||||
{
|
||||
InternalId = (uint)result["internalId"];
|
||||
GContext.Publish(new EvtAPISvrLoginSuccess()
|
||||
{
|
||||
playerId = UserId,
|
||||
internalId = InternalId
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
InternalId = 0;
|
||||
}
|
||||
playerId = session.PlayerId,
|
||||
internalId = InternalId
|
||||
});
|
||||
return true;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogError(e);
|
||||
InternalId = 0;
|
||||
if (generation == apiLoginGeneration)
|
||||
{
|
||||
InternalId = 0;
|
||||
apiSession?.Dispose();
|
||||
apiSession = null;
|
||||
customServerMgr.ClearAccountSession();
|
||||
}
|
||||
Debug.LogError($"API login failed: {e.GetType().Name}");
|
||||
return false;
|
||||
}
|
||||
|
||||
tryLoginApiSvr = false;
|
||||
}
|
||||
|
||||
public async virtual Task<(int code, int userid)> LinkAccount(EAccoutType accoutType)
|
||||
@@ -608,22 +671,44 @@ namespace game
|
||||
|
||||
public virtual void Logout()
|
||||
{
|
||||
#if UNITY_EDITOR && !SERVICE_HOST
|
||||
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { _=StopKVGameplayLoopAsync(); return; }
|
||||
#endif
|
||||
if(kvControlledInstance)
|
||||
{
|
||||
logoutGeneration++; InternalId=0; UserId=null; DisplayName=null; AvatarUrl=null;
|
||||
_=kvCoordinator?.Revoke();
|
||||
var previous=loginLifetime; loginLifetime=new System.Threading.CancellationTokenSource();
|
||||
try { previous.Cancel(); } finally { previous.Dispose(); }
|
||||
return;
|
||||
}
|
||||
|
||||
logoutGeneration++;
|
||||
apiLoginGeneration++;
|
||||
apiLoginTask = null;
|
||||
apiSession?.Dispose();
|
||||
apiSession = null;
|
||||
UserId = null;
|
||||
InternalId = 0;
|
||||
DisplayName = null;
|
||||
AvatarUrl = null;
|
||||
PlayFabClientAPI.ForgetAllCredentials();
|
||||
customServerMgr.ClearAccountSession();
|
||||
var previousLifetime = loginLifetime;
|
||||
loginLifetime = new System.Threading.CancellationTokenSource();
|
||||
try { previousLifetime.Cancel(); }
|
||||
finally { previousLifetime.Dispose(); }
|
||||
}
|
||||
|
||||
public virtual async Task<string> ChangeDisplayName(string newName)
|
||||
{
|
||||
var generation = logoutGeneration;
|
||||
var originName = string.IsNullOrEmpty(DisplayName) ? NewPlayerName : DisplayName;
|
||||
var result = await PlayFabClientAsyncAPI.UpdateUserTitleDisplayNameAsync(new UpdateUserTitleDisplayNameRequest
|
||||
{
|
||||
DisplayName = newName
|
||||
});
|
||||
if (result == null)
|
||||
if (generation != logoutGeneration || result == null)
|
||||
return originName;
|
||||
DisplayName = result.DisplayName;
|
||||
GContext.Publish(new ChangeAvatarEvent());
|
||||
@@ -633,6 +718,7 @@ namespace game
|
||||
|
||||
public virtual async Task<string> UpdateAvatarUrl(string url)
|
||||
{
|
||||
var generation = logoutGeneration;
|
||||
var originUrl = AvatarUrl;
|
||||
|
||||
Debug.Log("UpdateAvatarUrlAsync originUrl:" + originUrl + " url " + url);
|
||||
@@ -647,7 +733,7 @@ namespace game
|
||||
ImageUrl = url
|
||||
});
|
||||
Debug.Log("UpdateAvatarUrlAsync :" + result);
|
||||
if (result == null) return originUrl;
|
||||
if (generation != logoutGeneration || result == null) return originUrl;
|
||||
AvatarUrl = url;
|
||||
GContext.Publish(new ChangeAvatarEvent());
|
||||
UpdateProfileAvatar();
|
||||
@@ -656,6 +742,7 @@ namespace game
|
||||
|
||||
public virtual async Task<string> UpdateLanguage(string code)
|
||||
{
|
||||
var generation = logoutGeneration;
|
||||
if (Language == code)
|
||||
{
|
||||
return Language;
|
||||
@@ -666,7 +753,7 @@ namespace game
|
||||
};
|
||||
|
||||
var result = await PlayFabProfilesAsyncAPI.SetProfileLanguageAsync(setProfileLanguageRequest);
|
||||
if (result != null)
|
||||
if (generation == logoutGeneration && result != null)
|
||||
Language = code;
|
||||
return Language;
|
||||
}
|
||||
@@ -683,32 +770,36 @@ namespace game
|
||||
}
|
||||
public void UpdateProfile()
|
||||
{
|
||||
if (!IsAuthorized) return;
|
||||
PlayerProfile playerProfile = new PlayerProfile();
|
||||
playerProfile.Avatar = AvatarUrl;
|
||||
playerProfile.DisplayName = DisplayName;
|
||||
playerProfile.Level = GContext.container.Resolve<PlayerData>().lv;
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile };
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
|
||||
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
|
||||
}
|
||||
public void UpdateProfileLv()
|
||||
{
|
||||
if (!IsAuthorized) return;
|
||||
PlayerProfile playerProfile = new PlayerProfile();
|
||||
playerProfile.Level = GContext.container.Resolve<PlayerData>().lv;
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile };
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
|
||||
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
|
||||
}
|
||||
void UpdateProfileName()
|
||||
{
|
||||
if (!IsAuthorized) return;
|
||||
PlayerProfile playerProfile = new PlayerProfile();
|
||||
playerProfile.DisplayName = DisplayName;
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile };
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
|
||||
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
|
||||
}
|
||||
void UpdateProfileAvatar()
|
||||
{
|
||||
if (!IsAuthorized) return;
|
||||
PlayerProfile playerProfile = new PlayerProfile();
|
||||
playerProfile.Avatar = AvatarUrl;
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = UserId, playerProfile = playerProfile };
|
||||
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
|
||||
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
|
||||
}
|
||||
#region 其他玩家头像昵称缓存
|
||||
|
||||
@@ -52,6 +52,7 @@ namespace asap.core
|
||||
|
||||
private AsyncOperationHandle<AudioMixer> mixerHandle;
|
||||
private AsyncOperationHandle<GameObject> soundPrefabHandle;
|
||||
private int initializationVersion;
|
||||
|
||||
private Dictionary<Sound, CancellationTokenSource> SoundFadeTaskDic;
|
||||
private float bgmVol = 0;
|
||||
@@ -135,33 +136,56 @@ namespace asap.core
|
||||
public async Task InitAsync(string mixerAddr = "AudioMixer", string soundPrefabAddr = "sound",
|
||||
string bgmGroupId = "BGM", string fishingGroupId = "Fishing", string uiGroupId = "UI", string voiceGroupId = "Voice")
|
||||
{
|
||||
if (mixerHandle.IsValid() || soundPrefabHandle.IsValid() || lowpassEvtDisposable != null) Release();
|
||||
var version = ++initializationVersion;
|
||||
SoundFadeTaskDic = new Dictionary<Sound, CancellationTokenSource>();
|
||||
AsyncOperationHandle<AudioMixer> pendingMixer = default;
|
||||
AsyncOperationHandle<GameObject> pendingPrefab = default;
|
||||
IDisposable pendingSubscription = null;
|
||||
bool transferred = false;
|
||||
try
|
||||
{
|
||||
pendingMixer = Addressables.LoadAssetAsync<AudioMixer>(mixerAddr);
|
||||
var loadedMixer = await pendingMixer.Task;
|
||||
if (version != initializationVersion) return;
|
||||
mixer = loadedMixer;
|
||||
Assert.IsNotNull(mixer);
|
||||
mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol);
|
||||
mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol);
|
||||
|
||||
mixerHandle = Addressables.LoadAssetAsync<AudioMixer>(mixerAddr);
|
||||
mixer = await mixerHandle.Task;
|
||||
Assert.IsNotNull(mixer);
|
||||
mixer.GetFloat(Bgm_Vol_Param_Name, out bgmVol);
|
||||
mixer.GetFloat(Fishing_Vol_Param_Name, out fishingVol);
|
||||
bgmGroup = FindMixerGroup(bgmGroupId);
|
||||
fishingGroup = FindMixerGroup(fishingGroupId);
|
||||
uiGroup = FindMixerGroup(uiGroupId);
|
||||
voiceGroup = FindMixerGroup(voiceGroupId);
|
||||
normal = mixer.FindSnapshot("Snapshot");
|
||||
Assert.IsNotNull(bgmGroup);
|
||||
Assert.IsNotNull(fishingGroup);
|
||||
Assert.IsNotNull(uiGroup);
|
||||
|
||||
bgmGroup = FindMixerGroup(bgmGroupId);
|
||||
fishingGroup = FindMixerGroup(fishingGroupId);
|
||||
uiGroup = FindMixerGroup(uiGroupId);
|
||||
voiceGroup = FindMixerGroup(voiceGroupId);
|
||||
normal = mixer.FindSnapshot("Snapshot");// ("normal");
|
||||
//lowpass = mixer.FindSnapshot("lowpass");
|
||||
Assert.IsNotNull(bgmGroup);
|
||||
Assert.IsNotNull(fishingGroup);
|
||||
Assert.IsNotNull(uiGroup);
|
||||
pendingPrefab = Addressables.LoadAssetAsync<GameObject>(soundPrefabAddr);
|
||||
var soundPrefab = await pendingPrefab.Task;
|
||||
if (version != initializationVersion) return;
|
||||
Assert.IsNotNull(soundPrefab);
|
||||
var sound = soundPrefab.GetComponent<Sound>();
|
||||
Assert.IsNotNull(sound);
|
||||
|
||||
soundPrefabHandle = Addressables.LoadAssetAsync<GameObject>(soundPrefabAddr);
|
||||
var soundPrefab = await soundPrefabHandle.Task;
|
||||
Assert.IsNotNull(soundPrefab);
|
||||
var sound = soundPrefab.GetComponent<Sound>();
|
||||
Assert.IsNotNull(sound);
|
||||
|
||||
lowpassEvtDisposable = GContext.OnEvent<LowpassEffectEvt>().Subscribe(OnLowpassEffectEvt);
|
||||
|
||||
poolService.CreatePool<Sound>(sound, 10, 50);
|
||||
poolService.CreatePool<Sound>(sound, 10, 50);
|
||||
pendingSubscription = GContext.OnEvent<LowpassEffectEvt>().Subscribe(OnLowpassEffectEvt);
|
||||
mixerHandle = pendingMixer;
|
||||
soundPrefabHandle = pendingPrefab;
|
||||
lowpassEvtDisposable = pendingSubscription;
|
||||
transferred = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!transferred)
|
||||
{
|
||||
pendingSubscription?.Dispose();
|
||||
if (version == initializationVersion) Release();
|
||||
if (pendingPrefab.IsValid()) Addressables.Release(pendingPrefab);
|
||||
if (pendingMixer.IsValid()) Addressables.Release(pendingMixer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLowpassEffectEvt(LowpassEffectEvt evt)
|
||||
@@ -176,18 +200,24 @@ namespace asap.core
|
||||
|
||||
public void Release()
|
||||
{
|
||||
foreach (var source in SoundFadeTaskDic.Values)
|
||||
initializationVersion++;
|
||||
if (SoundFadeTaskDic != null)
|
||||
{
|
||||
source.Cancel();
|
||||
foreach (var source in SoundFadeTaskDic.Values)
|
||||
{
|
||||
source.Cancel();
|
||||
}
|
||||
SoundFadeTaskDic.Clear();
|
||||
SoundFadeTaskDic = null;
|
||||
}
|
||||
SoundFadeTaskDic.Clear();
|
||||
SoundFadeTaskDic = null;
|
||||
|
||||
poolService.DestroyPool<Sound>();
|
||||
Addressables.Release(mixerHandle);
|
||||
Addressables.Release(soundPrefabHandle);
|
||||
poolService?.DestroyPool<Sound>();
|
||||
if (mixerHandle.IsValid()) Addressables.Release(mixerHandle);
|
||||
mixerHandle = default;
|
||||
if (soundPrefabHandle.IsValid()) Addressables.Release(soundPrefabHandle);
|
||||
soundPrefabHandle = default;
|
||||
|
||||
lowpassEvtDisposable.Dispose();
|
||||
lowpassEvtDisposable?.Dispose();
|
||||
lowpassEvtDisposable = null;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user