chore(m2): 保存功能验收及后续清理前检查点

This commit is contained in:
Liubing\LB
2026-10-10 20:40:27 +08:00
parent d3c5394a55
commit 144e7f739f
110 changed files with 12672 additions and 7514 deletions

File diff suppressed because one or more lines are too long

View File

@@ -9,14 +9,22 @@ public static class KVGameLoadValidation
// Fixed assembly/type/method and explicit case allow-list; no arbitrary reflection arguments.
public static async Task<string> RunAsync(string caseId)
{
if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" &&
caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" &&
caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" &&
caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20") throw new ArgumentException("Unknown fixed validation case.");
if (caseId != "M08-M23" && caseId != "M21-M22-M24" && caseId != "M09-M10-M12" && caseId != "M14" && caseId != "M07" && caseId != "M20-M21-M22-M24-M28-M29" && caseId != "M16-M17" && caseId != "M06-M13" && caseId != "M15-M26-M27" && caseId != "M30" && caseId != "M19" && caseId != "M11" && caseId != "M25" && caseId != "M01-M02-M21-M22-M24" && caseId != "M04-M21-M22-M24" && caseId != "M05-M21-M22-M24" && caseId != "M03" && caseId != "M18" && caseId != "M31" && caseId != "B2-20" && caseId != "M01-Immediate" && caseId != "Module02-OriginalLoad" && caseId != "M03B-LoginRetry" && caseId != "M03B-LoginRetry-Cleanup" && caseId != "M03C-CustomId" && caseId != "M03D-Destroy")
{
if (caseId != "P6A-InitializationLastLogin" && caseId != "P6B-OrdinarySave" && caseId != "P6B-OrdinarySave-Cleanup" && caseId != "P8A-Leaderboard" && caseId != "P8B-Friends" && caseId != "P8C-TitleNews" && caseId != "P8C-TitleNews-Cleanup" && caseId != "P8D-Matchmaking" && caseId != "P12A-OrdinaryCadence" && caseId != "P12B-FormalReadAdapters" && caseId != "P12C-StartupSubscriptions" && caseId != "P14-FormalRealtime" && caseId != "P15-FormalMatchmaking" && caseId != "P15-FormalMatchState")
{
throw new ArgumentException("Unknown fixed validation case.");
}
}
var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate");
var type = assembly.GetType("game.PlayerDataStorage.PlayerKVEditorValidationHost", true);
var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null);
if (method == null) throw new InvalidOperationException("Validation entry unavailable.");
if (method == null)
{
throw new InvalidOperationException("Validation entry unavailable.");
}
return await (Task<string>)method.Invoke(null, new object[] { caseId });
}
}

View File

@@ -1,11 +1,9 @@
#if UNITY_EDITOR
#if UNITY_EDITOR
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Security.Cryptography;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
using UnityEngine;
@@ -19,11 +17,36 @@ public static class KVGameplayLoopSetup
public static async System.Threading.Tasks.Task<string> RunValidationAsync(string caseId)
{
if(caseId!="equipment-chain" && caseId!="fishing-consume" && caseId!="fishing-reward") throw new ArgumentException("Unknown fixed validation case.");
string methodName;
switch (caseId)
{
case "equipment-chain":
methodName = "VerifyEquipmentForEditorAsync";
break;
case "fishing-consume":
methodName = "VerifyFishingForEditorAsync";
break;
case "fishing-reward":
methodName = "VerifyFishingRewardForEditorAsync";
break;
case "fishing-original-reward":
methodName = "VerifyOriginalFishingRewardForEditorAsync";
break;
case "fishing-original-consume":
methodName = "VerifyOriginalFishingForEditorAsync";
break;
default:
throw new ArgumentException("Unknown fixed validation case.");
}
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating)
{
throw new InvalidOperationException("Idle editor required.");
var method=Find("PlayerKVGameplayLoop").GetMethod(caseId=="equipment-chain"?"VerifyEquipmentForEditorAsync":caseId=="fishing-consume"?"VerifyFishingForEditorAsync":"VerifyFishingRewardForEditorAsync",BindingFlags.Static|BindingFlags.NonPublic);
if(method==null) throw new InvalidOperationException("Fixed equipment validation entry unavailable.");
}
var method = Find("PlayerKVGameplayLoop").GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
if (method == null)
{
throw new InvalidOperationException("Fixed equipment validation entry unavailable.");
}
return await (System.Threading.Tasks.Task<string>)method.Invoke(null,null);
}
@@ -37,17 +60,6 @@ public static class KVGameplayLoopSetup
processMarkers=Directory.Exists(history)?Directory.GetFiles(history,"*.claimed").Select(Path.GetFileName).OrderBy(x=>x).ToArray():Array.Empty<string>()});
}
public static string ValidatePermit(string path)
{
var type=Find("PlayerKVLoopPermit");
try
{
var permit=type.GetMethod("Read",BindingFlags.Static|BindingFlags.NonPublic).Invoke(null,new object[]{Path.GetFullPath(path)});
return JsonConvert.SerializeObject(permit);
}
catch(TargetInvocationException e) { throw e.InnerException ?? e; }
}
public static string InspectPFBackup(string path)
{
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating)
@@ -57,29 +69,6 @@ public static class KVGameplayLoopSetup
catch(TargetInvocationException e) { throw e.InnerException ?? e; }
}
public static string PreparePermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,false);
public static string PrepareFishingPermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,true);
public static string PrepareFishingRewardPermit(string account,string backupPath,string[] allowedKeys)
=> PreparePermitCore(account,backupPath,allowedKeys,true,true);
static string PreparePermitCore(string account,string backupPath,string[] allowedKeys,bool fishing,bool reward=false)
{
if(EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling)
throw new InvalidOperationException("Stop play before preparing a new process run.");
backupPath=Path.GetFullPath(backupPath);
var bytes=File.ReadAllBytes(backupPath);
string hash;
using(var sha=SHA256.Create()) hash=BitConverter.ToString(sha.ComputeHash(bytes)).Replace("-","");
var candidate=Path.Combine(Root,"permit.candidate.json");
Directory.CreateDirectory(Root);
var json=JObject.FromObject(new{Version=reward?3:fishing?2:1,Enabled=true,PlayerId=account,
Api="http://s101.local:18080/",BackupPath=backupPath,BackupSha256=hash,AllowedKeys=allowedKeys});
if(fishing) json["Operation"]=reward?"fishing-reward-once":"fishing-consume-once";
File.WriteAllText(candidate,json.ToString(Formatting.Indented));
var validated=ValidatePermit(candidate); // Empty/mismatched backups cannot enable a run.
File.WriteAllText(Path.Combine(Root,"permit.json"),validated);
return State();
}
}
#endif

View File

@@ -0,0 +1,632 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using game;
using game.Account;
using game.PlayerDataStorage;
using Newtonsoft.Json;
using UnityEditor;
using UnityEngine;
// Fixed B01 synthetic cases only. No global container replacement, permission UI or Play mode.
public static class KVPromptStateValidation
{
private static bool running;
private static object failedCleanup;
private static readonly BindingFlags PrivateInstance = BindingFlags.Instance | BindingFlags.NonPublic;
public static async Task<string> RunAsync(string caseId)
{
if ((caseId != "B01" && caseId != "CP3A" && caseId != "P6D") || EditorApplication.isPlayingOrWillChangePlaymode || running || failedCleanup != null)
{
throw new InvalidOperationException("B01 requires an idle Editor and completed prior cleanup.");
}
running = true;
var run = new FixedRun();
var staticField = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic);
object previous = null;
var isolated = false;
var errors = new List<Exception>();
string result = null;
try
{
previous = staticField.GetValue(null);
staticField.SetValue(null, new RedPointManager());
isolated = true;
result = caseId == "P6D" ? await run.ExecuteP6D() : caseId == "B01" ? await run.Execute() : await run.ExecuteCP3A();
}
catch (Exception ex)
{
errors.Add(ex);
}
finally
{
try
{
await run.Close();
}
catch (Exception ex)
{
failedCleanup = run;
errors.Add(ex);
}
try
{
if (isolated)
{
staticField.SetValue(null, previous);
if (!ReferenceEquals(previous, staticField.GetValue(null)))
{
throw new InvalidOperationException("Red point instance was not restored.");
}
}
}
catch (Exception ex)
{
failedCleanup = run;
errors.Add(ex);
}
finally
{
running = false;
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
var report = Newtonsoft.Json.Linq.JObject.Parse(result);
report["restored"] = true;
report["remainingTestObjects"] = 0;
return report.ToString(Formatting.None);
}
sealed class Handler : HttpMessageHandler
{
readonly Func<HttpRequestMessage, HttpResponseMessage> send;
internal Handler(Func<HttpRequestMessage, HttpResponseMessage> send) { this.send = send; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage r, CancellationToken t) => Task.FromResult(send(r));
}
static HttpResponseMessage Json(object value) => new HttpResponseMessage(HttpStatusCode.OK)
{ Content = new StringContent(JsonConvert.SerializeObject(value)) };
sealed class FixedRun
{
private readonly List<FixedRun> children = new List<FixedRun>();
private readonly List<Action> restoreFaults = new List<Action>();
readonly Uri api = new Uri("http://b01-editor.invalid/");
readonly string cache = Path.GetFullPath(Path.Combine(Application.dataPath,
"../../CodexOutput/upserver/B3BatchMigration/B01Tests/unity-runs", Guid.NewGuid().ToString("N")));
readonly List<S101AccountSession> sessions = new List<S101AccountSession>();
readonly List<GameObject> objects = new List<GameObject>();
PlayerKVGameSessionBridge bridge;
long epoch = 1;
int assertions, requests;
void Check(bool value, string label) { assertions++; if (!value) throw new InvalidOperationException("B01: " + label); }
static object Call(object target, string method, params object[] args)
{
try { return target.GetType().GetMethod(method, PrivateInstance | BindingFlags.Public).Invoke(target, args); }
catch (TargetInvocationException ex) { ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); throw; }
}
static void Field(object target, string name, object value) => target.GetType().GetField(name, PrivateInstance).SetValue(target, value);
static T Bound<T>(PlayerKVGameSessionHandle handle) => (T)typeof(T).GetConstructor(PrivateInstance,
null, new[] { typeof(PlayerKVGameSessionHandle) }, null).Invoke(new object[] { handle });
void Reject(Action action)
{
try { action(); }
catch (InvalidOperationException) { assertions++; return; }
throw new InvalidOperationException("B01: expected owner rejection");
}
async Task<PlayerKVGameSessionHandle> Open()
{
var session = new S101AccountSession(api, jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "account/Login"), "synthetic login only");
jar.Add(api, new Cookie("sid", "101"));
return Json(new { state = 0, internalId = 101, newCreate = false,
createTime = "2026-01-01T00:00:00Z", svrTime = "2026-01-01T00:00:00Z" });
}));
sessions.Add(session);
Check((await session.LoginAsync("101")).Success, "synthetic login");
var opened = await bridge.OpenAsync(session, cache, "101", api, epoch, e => e == epoch);
Check(opened.Success, "synthetic owner open");
return opened.Handle;
}
T Panel<T>() where T : Component
{
var go = new GameObject("B01-" + typeof(T).Name) { hideFlags = HideFlags.HideAndDontSave };
go.SetActive(false); objects.Add(go);
return go.AddComponent<T>();
}
internal async Task<string> Execute()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "no write/no real transport");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
var handle = await Open();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
Check(!settings.ShowNotice, "valid missing key");
settings.ShowNotice = true;
Check(settings.ShowNotice && requests == 1, "accepted local only");
var original = settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings);
settings.ShowNotice = true;
Check(ReferenceEquals(original, settings.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(settings)), "same value no duplicate");
// A fresh real ClubService retains its original initialized flag.
Check(club.IsOpenClubRed, "fresh real club retains original flag");
await bridge.CloseAsync();
Reject(() => settings.ShowNotice = false);
Reject(club.SetOpenClubRed);
Check(club.IsOpenClubRed, "stale real club preserves flag");
// Notification constructor validates the current owner. Create a fresh fixed instance, then revoke it.
epoch++;
var nextHandle = await Open();
var nextSettings = Bound<DefaultSettingService>(nextHandle);
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(
Path.Combine(Application.dataPath, "../../cfgData", name + ".json"))));
var notifications = (LocalNotificationService)typeof(LocalNotificationService).GetConstructor(PrivateInstance,
null, new[] { typeof(DefaultSettingService), typeof(cfg.Tables) }, null).Invoke(new object[] { nextSettings, tables });
var pendingInit = notifications.Init();
await bridge.CloseAsync();
try { await pendingInit; throw new InvalidOperationException("B01: stale init succeeded"); }
catch (InvalidOperationException ex) { Check(!ex.Message.Contains("stale init succeeded"), "stale Editor permission continuation rejected"); }
var grade = Panel<SettingNotificationsGradePopupPanel>();
Field(grade, "settings", nextSettings); Field(grade, "notifications", notifications);
var popup = Panel<SettingNotificationsPopupPanel>(); Field(popup, "notifications", notifications);
var settingPanel = Panel<SettingPopupPanel>(); Field(settingPanel, "settingService", nextSettings); Field(settingPanel, "notificationService", notifications);
var gift = Panel<GiftNoticePanel>(); Field(gift, "notificationService", notifications);
var social = Panel<FishingSocialPanel>(); Field(social, "_socialData", club);
var errors = new List<string>();
void Capture(string message, string stack, LogType type) { if (type == LogType.Error || type == LogType.Exception) errors.Add(message); }
Application.logMessageReceived += Capture;
try
{
Reject(() => Call(grade, "DestroyUI"));
Reject(() => Call(grade, "OnBtnOk"));
Reject(() => Call(popup, "OnBtnOk"));
Reject(() => Call(popup, "DestroyUI"));
Reject(() => Call(settingPanel, "OnToggleNotification", true));
Reject(() => Call(settingPanel, "OnToggleNotification", false));
Reject(() => Call(gift, "OnClickYES"));
Call(social, "Start");
Check(errors.Count == 1, "only unchanged social entry logs its owner rejection");
Check(errors.TrueForAll(e => e.Contains("ShowNotice owner=") || e.Contains("IsOpenClubRed owner=")), "no unexpected exception");
foreach (var go in objects) Check(go != null, "stale callback did not destroy another UI");
}
finally { Application.logMessageReceived -= Capture; }
Check(requests == 2, "read-only synthetic transport");
return JsonConvert.SerializeObject(new { success = true, assertions, requests, formalRequests = 0,
playing = EditorApplication.isPlaying, globalContainerChanged = false, caseId = "B01", cache });
}
internal async Task<string> ExecuteP6D()
{
foreach (var value in new[] { null, "0", "1", "" })
{
var fixture = new FixedRun();
children.Add(fixture);
await fixture.HealthyPrompt(value);
}
foreach (var club in new[] { false, true })
{
foreach (var stage in new[] { "BeforeWrite", "AfterReplace" })
{
var fixture = new FixedRun();
children.Add(fixture);
await fixture.FaultPrompt(club, stage);
}
var revoked = new FixedRun();
children.Add(revoked);
await revoked.RevokedDuringPrompt(club);
}
var callback = new FixedRun();
children.Add(callback);
await callback.RevokedDuringClubCallback();
foreach (var fixture in children)
{
assertions += fixture.assertions;
requests += fixture.requests;
}
return JsonConvert.SerializeObject(new { success = true, caseId = "P6D", assertions,
cases = children.Count, requests, formalRequests = 0, playing = EditorApplication.isPlaying,
globalContainerChanged = false });
}
private async Task<PlayerKVGameSessionHandle> OpenPrompt()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "P6D synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
return await Open();
}
private static PlayerKVEditResult Edit(object service)
{
return (PlayerKVEditResult)service.GetType().GetProperty("LastPromptEdit", PrivateInstance).GetValue(service);
}
private static bool? Cached(DefaultSettingService settings)
{
return (bool?)typeof(DefaultSettingService).GetField("_showNotice", PrivateInstance).GetValue(settings);
}
private sealed class RedProbe : IUIRedPoint
{
private readonly Action<bool> callback;
internal RedProbe(Action<bool> callback)
{
this.callback = callback;
}
public void SetRedPointState(bool state)
{
callback(state);
}
}
private void ObserveRed(Action<bool> callback)
{
RedPointManager.Instance.RemoveRedPoint(RedPointName.Home_Club);
RedPointManager.Instance.AddRedPoint(RedPointName.Home_Club, new RedProbe(callback));
}
private static Delegate FaultDelegate<T>(Action<string> callback)
{
return new Action<T>(stage => callback(stage.ToString()));
}
private void InjectFault(PlayerKVGameSessionHandle handle, Action<string> callback)
{
var owner = handle.GetType().GetProperty("Owner", PrivateInstance).GetValue(handle);
var state = owner.GetType().GetField("state", PrivateInstance).GetValue(owner);
var store = state.GetType().GetField("store", PrivateInstance).GetValue(state);
var field = store.GetType().GetField("fault", PrivateInstance);
var previous = field.GetValue(store);
var method = typeof(FixedRun).GetMethod("FaultDelegate", BindingFlags.Static | BindingFlags.NonPublic)
.MakeGenericMethod(field.FieldType.GetGenericArguments()[0]);
var fault = method.Invoke(null, new object[] { callback });
restoreFaults.Add(() => field.SetValue(store, previous));
field.SetValue(store, fault);
Check(ReferenceEquals(field.GetValue(store), fault), "actual cache fault installed");
}
private async Task HealthyPrompt(string initial)
{
var handle = await OpenPrompt();
if (initial != null)
{
Check(handle.Update("ShowNotice", initial).Accepted, "seed initial value");
}
var settings = Bound<DefaultSettingService>(handle);
var expected = initial == "1";
Check(settings.ShowNotice == expected, "first read follows original string comparison");
Check(handle.Update("ShowNotice", expected ? "0" : "1").Accepted, "other consumer updates actual cache");
Check(settings.ShowNotice == expected, "first-read nullable cache survives other consumer writes");
var fresh = Bound<DefaultSettingService>(handle);
var target = !expected;
var observedWrites = 0;
InjectFault(handle, stage =>
{
if (stage == "BeforeWrite")
{
observedWrites++;
Check(Cached(fresh) == target, "nullable cache assigned before actual disk write");
}
});
fresh.ShowNotice = target;
Check(Edit(fresh)?.Accepted == true && observedWrites == 1, "setter before getter writes even when stored value matches");
var receipt = Edit(fresh);
fresh.ShowNotice = target;
Check(ReferenceEquals(receipt, Edit(fresh)) && observedWrites == 1, "same cached value does not write");
target = !target;
fresh.ShowNotice = target;
Check(observedWrites == 2 && !ReferenceEquals(receipt, Edit(fresh)), "changed cached value writes");
RestoreFaults();
Check(handle.Update("IsOpenClubRed", "0").Accepted, "seed present club value");
var club = Bound<ClubService>(handle);
var calls = 0;
ObserveRed(red =>
{
calls++;
Check(!red && !club.IsOpenClubRed, "flag clears before red callback");
Check(handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == (calls == 1 ? "0" : "1"), "red callback precedes write");
});
club.SetOpenClubRed();
Check(calls == 1 && Edit(club)?.Accepted == true && handle.ReadLocalSnapshot().Data["IsOpenClubRed"] == "1", "present key does not replace original flag gate");
var clubReceipt = Edit(club);
club.SetOpenClubRed();
Check(calls == 1 && ReferenceEquals(clubReceipt, Edit(club)), "false flag skips red and write");
club.IsOpenClubRed = true;
club.SetOpenClubRed();
Check(calls == 2 && !ReferenceEquals(clubReceipt, Edit(club)), "true flag writes existing value");
// A diagnostic result is not a second owner state. Do not unlock a real failed owner.
var rejected = handle.Update(null, "1");
Check(!rejected.Accepted, "actual rejected diagnostic");
fresh.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(fresh, rejected);
club.GetType().GetProperty("LastPromptEdit", PrivateInstance).SetValue(club, rejected);
Check(fresh.ShowNotice == target, "healthy owner ignores old rejected diagnostic");
club.SetOpenClubRed();
Check(calls == 2, "healthy false club ignores diagnostic without business effects");
await bridge.CloseAsync();
Reject(() =>
{
_ = fresh.ShowNotice;
});
Reject(() => fresh.ShowNotice = target);
Reject(club.SetOpenClubRed);
Check(calls == 2 && Cached(fresh) == target && !club.IsOpenClubRed, "stale cache-hit entries cause no effects");
}
private async Task FaultPrompt(bool useClub, string failureStage)
{
var handle = await OpenPrompt();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
Check(!red && !club.IsOpenClubRed, "failed write still follows original red order");
});
var faults = 0;
InjectFault(handle, stage =>
{
if (stage == failureStage)
{
faults++;
Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true,
"business state precedes actual storage failure");
throw new IOException("P6D synthetic " + failureStage);
}
});
Reject(() =>
{
if (useClub)
{
club.SetOpenClubRed();
}
else
{
settings.ShowNotice = true;
}
});
var edit = Edit(useClub ? (object)club : settings);
Check(faults == 1 && edit != null && !edit.Accepted, "actual rejected write preserved");
Check(edit.StateResult.Error == PlayerKVChangeError.StorageError &&
edit.StateResult.LocalCommitMayHaveSucceeded == (failureStage == "AfterReplace"), "actual uncertainty preserved");
Check(handle.Status == PlayerKVSyncStatus.StorageError, "actual storage terminal state retained");
Reject(() =>
{
_ = settings.ShowNotice;
});
Reject(() => settings.ShowNotice = true);
Reject(club.SetOpenClubRed);
Check(faults == 1 && redCalls == (useClub ? 1 : 0), "actual unreadable owner blocks repeat effects");
Check(useClub ? !club.IsOpenClubRed : Cached(settings) == true, "no added rollback");
}
private async Task RevokedDuringPrompt(bool useClub)
{
var handle = await OpenPrompt();
var settings = Bound<DefaultSettingService>(handle);
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
});
InjectFault(handle, stage =>
{
if (stage == "AfterReplace")
{
epoch++;
}
});
Reject(() =>
{
if (useClub)
{
club.SetOpenClubRed();
}
else
{
settings.ShowNotice = true;
}
});
Check(Edit(useClub ? (object)club : settings)?.Accepted == true, "completed disk write not disguised as unaccepted");
Check(useClub ? !club.IsOpenClubRed && redCalls == 1 : Cached(settings) == true, "revocation does not roll back original front effects");
Check(!handle.ReadLocalSnapshot().Success && requests == 1, "revoked old owner has no replay or transport write");
}
private async Task RevokedDuringClubCallback()
{
var handle = await OpenPrompt();
var club = Bound<ClubService>(handle);
var redCalls = 0;
ObserveRed(red =>
{
redCalls++;
epoch++;
});
var writes = 0;
InjectFault(handle, stage =>
{
writes++;
});
Reject(club.SetOpenClubRed);
Check(!club.IsOpenClubRed && redCalls == 1, "callback revocation preserves front effects");
Check(Edit(club)?.Accepted == false && writes == 0 && requests == 1, "callback revocation refuses old write without replay");
}
internal async Task<string> ExecuteCP3A()
{
Func<CookieContainer, HttpMessageHandler> factory = jar => new Handler(r =>
{
Check(r.RequestUri == new Uri(api, "playerdata/Get"), "CP3A synthetic Get only");
requests++;
return Json(new { data = new Dictionary<string, string>() });
});
bridge = (PlayerKVGameSessionBridge)typeof(PlayerKVGameSessionBridge).GetConstructor(PrivateInstance,
null, new[] { typeof(Func<CookieContainer, HttpMessageHandler>), typeof(bool) }, null).Invoke(new object[] { factory, false });
var tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(File.ReadAllText(
Path.Combine(Application.dataPath, "../../cfgData", name + ".json"))));
var settingCtor = typeof(DefaultSettingService).GetConstructor(PrivateInstance,
null, new[] { typeof(bool) }, null);
var clubCtor = typeof(ClubService).GetConstructor(PrivateInstance,
null, new[] { typeof(bool) }, null);
var notificationCtor = typeof(LocalNotificationService).GetConstructor(PrivateInstance,
null, new[] { typeof(cfg.Tables) }, null);
Check(settingCtor != null && clubCtor != null && notificationCtor != null,
"formal service constructors exist");
var settings = (DefaultSettingService)settingCtor.Invoke(new object[] { true });
var club = (ClubService)clubCtor.Invoke(new object[] { true });
var notifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables });
var pendingNotifications = (LocalNotificationService)notificationCtor.Invoke(new object[] { tables });
var pendingInit = pendingNotifications.Init();
Reject(() => Call(pendingNotifications, "PreflightFormalPromptSettings", settings));
await pendingInit;
await notifications.Init();
Reject(() => { _ = settings.ShowNotice; });
Reject(() => settings.ShowNotice = true);
Reject(club.SetOpenClubRed);
var handle = await Open();
Call(settings, "BindFormalPromptOwner", handle);
Call(club, "BindFormalPromptOwner", handle);
Call(notifications, "BindFormalPromptSettings", settings);
Call(settings, "BindFormalPromptOwner", handle);
Call(club, "BindFormalPromptOwner", handle);
Call(notifications, "BindFormalPromptSettings", settings);
Check(!settings.ShowNotice, "bound missing ShowNotice");
settings.ShowNotice = true;
club.SetOpenClubRed();
Check(settings.ShowNotice && !club.IsOpenClubRed, "bound prompt effects");
Check(requests == 1, "one synthetic KV Get and no transport write");
await bridge.CloseAsync();
Reject(() => { _ = settings.ShowNotice; });
Reject(club.SetOpenClubRed);
epoch++;
var nextHandle = await Open();
Reject(() => Call(settings, "BindFormalPromptOwner", nextHandle));
Reject(() => Call(club, "BindFormalPromptOwner", nextHandle));
var partialSetting = (DefaultSettingService)settingCtor.Invoke(new object[] { true });
var partialClub = (ClubService)clubCtor.Invoke(new object[] { true });
Call(partialSetting, "BindFormalPromptOwner", nextHandle);
await bridge.CloseAsync();
Reject(() => Call(partialClub, "BindFormalPromptOwner", nextHandle));
Reject(() => { _ = partialSetting.ShowNotice; });
epoch++;
var thirdHandle = await Open();
Reject(() => Call(partialSetting, "BindFormalPromptOwner", thirdHandle));
Check(requests == 3, "same owner binds and partial failure add no transport requests");
return JsonConvert.SerializeObject(new { success = true, caseId = "CP3A", assertions,
requests, formalRequests = 0, globalContainerChanged = false });
}
internal async Task Close()
{
var errors = new List<Exception>();
foreach (var child in children)
{
try
{
await child.Close();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
try
{
RestoreFaults();
}
catch (Exception ex)
{
errors.Add(ex);
}
try
{
if (bridge != null)
{
await bridge.CloseAsync();
}
}
catch (Exception ex)
{
errors.Add(ex);
}
foreach (var session in sessions)
{
try
{
session.Dispose();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
foreach (var go in objects)
{
try
{
if (go != null)
{
UnityEngine.Object.DestroyImmediate(go);
}
if (go != null)
{
throw new InvalidOperationException("Test object survived cleanup.");
}
}
catch (Exception ex)
{
errors.Add(ex);
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
}
private void RestoreFaults()
{
var errors = new List<Exception>();
foreach (var restore in restoreFaults)
{
try
{
restore();
}
catch (Exception ex)
{
errors.Add(ex);
}
}
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
restoreFaults.Clear();
}
}
}
#endif

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 67d5bde18c99423aa2168a5af1c2b0da

View File

@@ -1,19 +0,0 @@
#if UNITY_EDITOR
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
public static class KVSaveRouteValidation
{
public static async Task<string> RunAsync(string caseId)
{
if (caseId != "fixed-route") throw new ArgumentException("Unknown fixed validation case.");
var assembly = AppDomain.CurrentDomain.GetAssemblies().Single(a => a.GetName().Name == "HotUpdate");
var type = assembly.GetType("game.PlayerDataStorage.PlayerKVSaveRouteEditorValidationHost", true);
var method = type.GetMethod("RunAsync", BindingFlags.Static | BindingFlags.NonPublic, null, new[] { typeof(string) }, null);
if (method == null) throw new InvalidOperationException("Validation entry unavailable.");
return await (Task<string>)method.Invoke(null, new object[] { caseId });
}
}
#endif

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: dc9b5dbb615a4746a95df66b46987589

View File

@@ -4068,7 +4068,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0}
m_AnchorMax: {x: 0.5, y: 0}
m_AnchoredPosition: {x: 0, y: 109.05}
m_AnchoredPosition: {x: 0, y: 109.049805}
m_SizeDelta: {x: 0, y: 74.69995}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &2775237510373940736

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using game;
using GameCore;
@@ -104,23 +104,10 @@ 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;

View File

@@ -1,19 +1,156 @@
using System;
using UnityEngine;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using game.PlayerDataStorage;
internal static class KVMgr
internal class KVMgr : MonoBehaviour
{
private static KVMgr instance;
private KVPlayerSaveStore store;
private CancellationTokenSource tokenSource;
private void Awake()
{
instance = this;
tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
internal static void Bind(KVPlayerSaveStore original)
{
if (instance != null)
{
instance.store = original;
}
}
internal static void Unbind(KVPlayerSaveStore original)
{
if (instance != null && ReferenceEquals(instance.store, original))
{
instance.store = null;
}
}
internal static void Stop(KVPlayerSaveStore original)
{
if (instance != null && ReferenceEquals(instance.store, original))
{
instance.Stop();
}
}
private void Stop()
{
tokenSource?.Cancel();
tokenSource?.Dispose();
tokenSource = null;
}
private async void UpdateUserDataValue(CancellationToken token)
{
try
{
while (!token.IsCancellationRequested)
{
SyncData();
await Task.Delay(40000);
}
}
catch (Exception ex)
{
Debug.LogError("KV save schedule failed: " + ex.GetType().Name);
}
}
private void SyncData()
{
var original = store;
if (original != null && original.IsCurrent)
{
ObserveSync(original.SyncDataAsync());
}
}
private static async void ObserveSync(Task<PlayerKVFlushResult> task)
{
try
{
var result = await task;
if (result.TransportError != PlayerKVError.None || result.Blocked != 0 || result.Unknown != 0)
{
Debug.LogError("KV ordinary save failed: " + result.TransportError);
}
}
catch (Exception ex)
{
Debug.LogError("KV ordinary save failed: " + ex.GetType().Name);
}
}
private void OnApplicationPause(bool paused)
{
if (paused)
{
Stop();
SyncData();
}
else
{
tokenSource = new CancellationTokenSource();
UpdateUserDataValue(tokenSource.Token);
}
}
private void OnApplicationQuit()
{
Stop();
}
private void OnDestroy()
{
Stop();
SyncData();
store = null;
if (ReferenceEquals(instance, this))
{
instance = null;
}
}
internal static Task<PlayerKVFlushResult> SyncKVAsync(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.SyncDataAsync(waitToken);
}
internal static PlayerKVKeyReadResult ReadKV(PlayerKVGameSessionHandle handle, string key)
{
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.ReadLocal))
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle);
{
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.LoadGuardActive, handle);
}
if (handle == null || !KVSnapshot.ValidKey(key))
return new PlayerKVKeyReadResult(PlayerKVKeyReadError.InvalidInput, handle);
{
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);
{
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);
}
@@ -22,20 +159,58 @@ internal static class KVMgr
string key, string value, bool remoteStore = true)
{
if (PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Update))
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
{
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
}
if (handle == null)
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation));
return handle.Update(key, value, remoteStore);
{
return new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Validation));
}
return handle.Update(key, value, remoteStore, ZZTimeHelper.UtcNow().Ticks);
}
internal static async Task<bool> SyncDataImmidiate(PlayerKVGameSessionHandle handle, string key, string value)
{
var success = false;
if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null &&
KVSnapshot.ValidKey(key) && value != null)
{
success = await handle.SyncDataImmidiate(key, value);
}
if (!success)
{
UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed.");
}
return success;
}
internal static async Task<bool> SyncDataImmidiate(PlayerKVGameSessionHandle handle,
IReadOnlyDictionary<string, string> values)
{
var success = false;
if (!PlayerKVGameLoadGuard.Reject(PlayerKVGuardPoint.Immediate) && handle != null && values != null && values.Count > 0)
{
success = await handle.SyncDataImmidiate(values);
}
if (!success)
{
UnityEngine.Debug.LogError("[KVMgr::SyncDataImmidiate] Save failed.");
}
return success;
}
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));
{
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 Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
}
return handle.FlushAsync(waitToken);
}
@@ -43,10 +218,14 @@ internal static class KVMgr
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));
{
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 Task.FromResult(new PlayerKVTicketResult(PlayerKVTicketStatus.Blocked, PlayerKVError.Validation, PlayerKVSyncStatus.NotLoaded));
}
return handle.WaitForTicketAsync(ticket, waitToken);
}
}

View File

@@ -26,8 +26,6 @@ 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
@@ -45,9 +43,6 @@ 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}";
@@ -124,24 +119,11 @@ public class PlayFabMgr : MonoBehaviour
{
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>>();
@@ -154,14 +136,6 @@ 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)
{
@@ -178,9 +152,6 @@ 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;
@@ -189,9 +160,6 @@ 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;
@@ -215,9 +183,6 @@ 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)
{
@@ -248,9 +213,6 @@ 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))
{
@@ -264,9 +226,6 @@ 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;
@@ -320,13 +279,9 @@ public class PlayFabMgr : MonoBehaviour
GameDebug.LogError("[PlayFabMgr]UpdateUserData Error: {0}", error.ToString());
}
internal void FlushBufferedData() => SyncData();
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)
{

View File

@@ -32,30 +32,14 @@ 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)
{

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using GameCore;
using UnityEngine;
@@ -15,61 +15,13 @@ 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;

View File

@@ -37,28 +37,9 @@ 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;

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using GameCore;
using System;
@@ -19,38 +19,10 @@ public class BuffDataCenter
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))

View File

@@ -1,4 +1,4 @@
using Activity.Services;
using Activity.Services;
using asap.core;
using Assets.Scripts.UI.SocialRush;
using cfg;
@@ -141,45 +141,9 @@ 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)
{

View File

@@ -58,19 +58,9 @@ namespace GameCore
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;
@@ -119,13 +109,7 @@ 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++)
@@ -178,29 +162,7 @@ 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;

View File

@@ -166,22 +166,28 @@ namespace GameCore
}
if (userService is KVUserService kvUser)
{
var store = kvUser.SaveStore;
var store = kvUser.SaveStore as KVPlayerSaveStore;
var writtenNames = new HashSet<string>(StringComparer.Ordinal);
bool accepted = false;
var data = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var statistic in Statistics)
{
if (!writtenNames.Add(statistic.StatisticName)) continue;
var write = store.Write(statistic.StatisticName, statistic.Value.ToString(CultureInfo.InvariantCulture));
if (write.Accepted) accepted = true;
else Debug.LogError("KV statistic write failed: " + statistic.StatisticName + ": " + write.Error);
if (!writtenNames.Add(statistic.StatisticName))
{
continue;
}
if (!KVSnapshot.ValidKey(statistic.StatisticName))
{
Debug.LogError("KV statistic write failed: " + statistic.StatisticName + ": Validation");
continue;
}
data.Add(statistic.StatisticName, statistic.Value.ToString(CultureInfo.InvariantCulture));
}
if (accepted)
if (data.Count > 0)
{
var confirmation = await store.FlushAsync();
if (confirmation.Status != PlayerSaveStatus.Confirmed)
Debug.LogError("KV statistic save not confirmed: " + string.Join(",", writtenNames) +
": " + confirmation.Status + ": " + confirmation.Error);
if (store == null || !await store.SyncDataImmidiate(data))
{
Debug.LogError("KV statistic save failed: " + string.Join(",", data.Keys));
}
}
return;
}
@@ -193,6 +199,35 @@ namespace GameCore
}
public async Task<GetLeaderboardResult> GetLeadboardData(string leaderboardNaem, int maxCount = 50)
{
if (userService is KVUserService)
{
var json = await customServerMgr.CustomServerPost("leaderboard/GetLeaderboard",
Newtonsoft.Json.JsonConvert.SerializeObject(new
{
statisticName = leaderboardNaem,
startPosition = 0,
maxResultsCount = maxCount
}));
if (string.IsNullOrEmpty(json))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<KVLeaderboardResponse>(json);
if (response == null || response.Code != 1 || response.Leaderboard == null ||
response.Leaderboard.Any(entry => entry == null || entry.Profile == null))
{
return null;
}
return new GetLeaderboardResult { Leaderboard = response.Leaderboard };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV leaderboard response failed: " + ex.GetType().Name);
return null;
}
}
GetLeaderboardRequest reques = new GetLeaderboardRequest()
{
StartPosition = 0,
@@ -209,6 +244,21 @@ namespace GameCore
return await PlayFabClientAsyncAPI.GetLeaderboardAsync(reques);
}
private sealed class KVLeaderboardResponse
{
public int Code
{
get;
set;
}
public List<PlayerLeaderboardEntry> Leaderboard
{
get;
set;
}
}
public async void SetLvData()
{
isRefreshing = true;

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using Assets.Scripts.UI.OfferSweep;
using cfg;
using CodeStage.AntiCheat.ObscuredTypes;
@@ -36,41 +36,9 @@ namespace GameCore
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; }
@@ -1140,57 +1108,6 @@ 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(",");
@@ -1231,11 +1148,7 @@ 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
playerSaveStore.Write("NewPlayerDataDic", Newtonsoft.Json.JsonConvert.SerializeObject(NewPlayerDataDic), remote).RequireAccepted();
return null;
}
public void Init()
{

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Linq;
using UnityEngine;
using cfg;
@@ -86,45 +86,9 @@ 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)
{
@@ -1195,14 +1159,6 @@ 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;

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using CodeStage.AntiCheat.ObscuredTypes;
using game;
@@ -133,43 +133,10 @@ 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)
{

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using game;
using LitJson;
@@ -39,18 +39,9 @@ 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
{

View File

@@ -48,25 +48,9 @@ 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; }

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using game;
using LitJson;
@@ -55,28 +55,9 @@ 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>();

View File

@@ -21,18 +21,10 @@ 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; } }

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -104,51 +104,10 @@ 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; }

View File

@@ -450,38 +450,11 @@ 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
{

View File

@@ -127,54 +127,11 @@ 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()

View File

@@ -95,45 +95,11 @@ 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()
{

View File

@@ -13,19 +13,9 @@ 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)
{

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using game;
using GameCore;
@@ -63,44 +63,11 @@ 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;
#if UNITY_EDITOR
_cheatingScore=0;
#endif
TopRankTier=Rank=FullStep=PlayerFullNumber=PreScore=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; }

View File

@@ -53,27 +53,10 @@ namespace DataCenter
// 当前可保存数据
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

View File

@@ -62,27 +62,10 @@ 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; //

View File

@@ -36,38 +36,13 @@ 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

View File

@@ -473,20 +473,28 @@ public class DebugFunc
private static void LoginTime(int hours)
{
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService;
if (userService == null) return;
var userService = GContext.container.Resolve<game.IUserService>();
if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
var loginTime = userService.LoginTime;
var newLoginTime = loginTime.AddHours(hours);
var prop = userService.GetType().GetProperty("LoginTime");
var prop = userService is game.KVUserService
? typeof(game.KVUserService).GetProperty("LoginTime")
: userService.GetType().GetProperty("LoginTime");
prop.SetValue(userService, newLoginTime);
Debug.Log($"loginTime: {loginTime} newLoginTime: {newLoginTime} prop: {userService.LoginTime}");
}
private static void AccoutInfo()
{
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService;
if (userService == null) return;
var userService = GContext.container.Resolve<game.IUserService>();
if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
Debug.Log($"pfid: {userService.UserId} tyid: {userService.CustomId} lastLogin: {userService.LoginTime}");
if (PlayerPrefs.HasKey("KEY_ACCOUNT_INFO"))

View File

@@ -607,7 +607,8 @@ public class EventFishingDuelPanel : BasePanel
}
void CreateMatchmakingTicket()
{
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService)
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService &&
GContext.container.Resolve<IUserService>() is not KVUserService)
{
Debug.LogWarning("KV matchmaking service is not connected.");
FDM.MachingEnd(null);
@@ -631,46 +632,58 @@ public class EventFishingDuelPanel : BasePanel
IUserService userService = GContext.container.Resolve<IUserService>();
List<string> Group = FDM.GetGroups();
string details = GetDetails();
var request = new CreateMatchmakingTicketRequest
var attributes = new
{
Creator = new MatchmakingPlayer
{
Entity = new EntityKey
{
Id = ((PlayFabUserService)userService).entityKey.Id,
Type = ((PlayFabUserService)userService).entityKey.Type
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = new
{
Tier = matchTierListStr.ToArray(),
Map = mapIDsStr.ToArray(),
Group = Group.ToArray(),
Details = details,
}
}
},
GiveUpAfterSeconds = MaxMatchTime,
QueueName = FishingDuelManager.QueueName // 需要根据实际的匹配队列名称修改
Tier = matchTierListStr.ToArray(),
Map = mapIDsStr.ToArray(),
Group = Group.ToArray(),
Details = details,
};
PlayFabMultiplayerAPI.CreateMatchmakingTicket(request,
(result) =>
Action<CreateMatchmakingTicketResult> success = (result) =>
{
currentTicketId = result.TicketId;
Debug.Log("匹配请求已创建,等待匹配...");
Debug.Log("匹配Ticket ID: " + result.TicketId);
//// 开始轮询匹配状态
StartCoroutine(CheckMatchmakingStatus());
//显示取消按钮
but_stop.gameObject.SetActive(true);
};
Action<string> failure = (error) =>
{
Debug.LogError("创建匹配请求失败: " + error);
FDM.MachingEnd(null);
};
if (userService is KVUserService)
{
RequestKVMatch("CreateMatchmakingTicket", new
{
currentTicketId = result.TicketId;
Debug.Log("匹配请求已创建,等待匹配...");
Debug.Log("匹配Ticket ID: " + result.TicketId);
//// 开始轮询匹配状态
StartCoroutine(CheckMatchmakingStatus());
//显示取消按钮
but_stop.gameObject.SetActive(true);
},
(error) =>
queueName = FishingDuelManager.QueueName,
giveUpAfterSeconds = MaxMatchTime,
attributes
}, response => response.ToObject<CreateMatchmakingTicketResult>(), success, failure);
}
else
{
var request = new CreateMatchmakingTicketRequest
{
Debug.LogError("创建匹配请求失败: " + error.GenerateErrorReport());
FDM.MachingEnd(null);
});
Creator = new MatchmakingPlayer
{
Entity = new EntityKey
{
Id = ((PlayFabUserService)userService).entityKey.Id,
Type = ((PlayFabUserService)userService).entityKey.Type
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = attributes
}
},
GiveUpAfterSeconds = MaxMatchTime,
QueueName = FishingDuelManager.QueueName
};
PlayFabMultiplayerAPI.CreateMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
}
IEnumerator CheckMatchmakingStatus()
@@ -680,7 +693,9 @@ public class EventFishingDuelPanel : BasePanel
yield return new WaitForSeconds(3f);
if (string.IsNullOrEmpty(currentTicketId))
{
yield break;
}
var request = new GetMatchmakingTicketRequest
{
@@ -689,41 +704,53 @@ public class EventFishingDuelPanel : BasePanel
EscapeObject = false
};
PlayFabMultiplayerAPI.GetMatchmakingTicket(request,
(result) =>
Action<GetMatchmakingTicketResult> success = (result) =>
{
string statusInfo = "匹配状态: " + result.Status;
if (result.MatchId != null)
{
string statusInfo = "匹配状态: " + result.Status;
if (result.MatchId != null)
{
statusInfo += ", Match ID: " + result.MatchId;
}
Debug.Log(statusInfo.ToString());
statusInfo += ", Match ID: " + result.MatchId;
}
Debug.Log(statusInfo.ToString());
if (result.Status == "Matched")
{
but_stop.gameObject.SetActive(false);
Debug.Log("匹配成功,获取服务器信息: ");
GetMatchDetails(result.MatchId);
}
else if (result.Status == "Canceled")
{
Debug.Log("匹配已取消: Canceled " + result.CancellationReasonString);
//if (result.CancellationReasonString != "Requested")
//{
MachingEnd();
//}
}
},
(error) =>
if (result.Status == "Matched")
{
Debug.LogError("检查匹配状态失败: " + error.GenerateErrorReport());
but_stop.gameObject.SetActive(false);
Debug.Log("匹配成功,获取服务器信息: ");
GetMatchDetails(result.MatchId);
}
else if (result.Status == "Canceled")
{
Debug.Log("匹配已取消: Canceled " + result.CancellationReasonString);
//if (result.CancellationReasonString != "Requested")
//{
MachingEnd();
});
//}
}
};
Action<string> failure = (error) =>
{
Debug.LogError("检查匹配状态失败: " + error);
MachingEnd();
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("GetMatchmakingTicket", new
{
queueName = request.QueueName,
ticketId = request.TicketId
}, response => response.ToObject<GetMatchmakingTicketResult>(), success, failure);
}
else
{
PlayFabMultiplayerAPI.GetMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
}
}
void GetMatchDetails(string matchId)
{
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService)
if (GContext.container.Resolve<IUserService>() is not PlayFabUserService &&
GContext.container.Resolve<IUserService>() is not KVUserService)
{
Debug.LogWarning("KV matchmaking service is not connected.");
FDM.MachingEnd(null);
@@ -737,52 +764,63 @@ public class EventFishingDuelPanel : BasePanel
ReturnMemberAttributes = true
};
PlayFabMultiplayerAPI.GetMatch(request,
(result) =>
Action<GetMatchResult> success = (result) =>
{
Debug.Log("匹配成功: " + result.MatchId);
if (string.IsNullOrEmpty(currentTicketId))
{
Debug.Log("匹配成功: " + result.MatchId);
if (string.IsNullOrEmpty(currentTicketId))
return;
}
// 添加玩家信息
if (result.Members != null && result.Members.Count > 0)
{
//AddMatchmakingLog("玩家数量: " + result.Members.Count);
IUserService userService = GContext.container.Resolve<IUserService>();
MatchDetails matchDetails = null;
int[] mapArray = null;
try
{
return;
}
// 添加玩家信息
if (result.Members != null && result.Members.Count > 0)
{
//AddMatchmakingLog("玩家数量: " + result.Members.Count);
IUserService userService = GContext.container.Resolve<IUserService>();
MatchDetails matchDetails = null;
int[] mapArray = null;
try
for (int i = 0; i < result.Members.Count; i++)
{
for (int i = 0; i < result.Members.Count; i++)
MatchmakingPlayerWithTeamAssignment member = result.Members[i];
if (member.Entity.Id != (userService is KVUserService ? userService.UserId : ((PlayFabUserService)userService).entityKey.Id))
{
MatchmakingPlayerWithTeamAssignment member = result.Members[i];
if (member.Entity.Id != ((PlayFabUserService)userService).entityKey.Id)
{
JObject dataObject = JObject.FromObject(result.Members[i].Attributes.DataObject);
string[] tierArray = dataObject["Tier"].ToObject<string[]>();
mapArray = dataObject["Map"].ToObject<int[]>();
string matchDetailsStr = dataObject["Details"].ToString();
matchDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<MatchDetails>(matchDetailsStr);
break;
}
JObject dataObject = JObject.FromObject(result.Members[i].Attributes.DataObject);
string[] tierArray = dataObject["Tier"].ToObject<string[]>();
mapArray = dataObject["Map"].ToObject<int[]>();
string matchDetailsStr = dataObject["Details"].ToString();
matchDetails = Newtonsoft.Json.JsonConvert.DeserializeObject<MatchDetails>(matchDetailsStr);
break;
}
}
catch
{
matchDetails = null;
mapArray = null;
}
currentTicketId = null;
FDM.MachingEnd(result.MatchId, matchDetails, mapArray);
StartCoroutine(CheckReady());
}
},
(error) =>
catch
{
matchDetails = null;
mapArray = null;
}
currentTicketId = null;
FDM.MachingEnd(result.MatchId, matchDetails, mapArray);
StartCoroutine(CheckReady());
}
};
Action<string> failure = (error) =>
{
Debug.LogError("获取匹配详情失败: " + error);
MachingEnd();
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("GetMatch", new
{
Debug.LogError("获取匹配详情失败: " + error.GenerateErrorReport());
MachingEnd();
});
queueName = request.QueueName,
matchId = request.MatchId
}, ReadKVMatch, success, failure);
}
else
{
PlayFabMultiplayerAPI.GetMatch(request, success, error => failure(error.GenerateErrorReport()));
}
}
IEnumerator CheckReady()
{
@@ -806,7 +844,9 @@ public class EventFishingDuelPanel : BasePanel
void CancelMatchmaking()
{
if (string.IsNullOrEmpty(currentTicketId))
{
return;
}
but_stop.gameObject.SetActive(false);
var request = new CancelMatchmakingTicketRequest
{
@@ -814,15 +854,98 @@ public class EventFishingDuelPanel : BasePanel
QueueName = FishingDuelManager.QueueName // 需要根据实际的匹配队列名称修改
};
currentTicketId = null;
PlayFabMultiplayerAPI.CancelMatchmakingTicket(request,
(result) =>
Action<CancelMatchmakingTicketResult> success = (result) =>
{
Debug.Log("匹配已取消: ");
};
Action<string> failure = (error) =>
{
Debug.LogError("取消匹配失败: " + error);
};
if (GContext.container.Resolve<IUserService>() is KVUserService)
{
RequestKVMatch("CancelMatchmakingTicket", new
{
Debug.Log("匹配已取消: ");
},
(error) =>
queueName = request.QueueName,
ticketId = request.TicketId
}, response => response.ToObject<CancelMatchmakingTicketResult>(), success, failure);
}
else
{
PlayFabMultiplayerAPI.CancelMatchmakingTicket(request, success, error => failure(error.GenerateErrorReport()));
}
}
private static async void RequestKVMatch<T>(string action, object request, Func<JObject, T> parse,
Action<T> success, Action<string> failure)
{
string body = await GContext.container.Resolve<ICustomServerMgr>().CustomServerPost("matchmaking/" + action,
Newtonsoft.Json.JsonConvert.SerializeObject(request));
T result = default;
string error = "KV matchmaking request failed";
bool accepted = false;
try
{
if (!string.IsNullOrEmpty(body))
{
Debug.LogError("取消匹配失败: " + error.GenerateErrorReport());
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<JObject>(body);
if (response != null && response["code"]?.ToObject<int>(Newtonsoft.Json.JsonSerializer.CreateDefault()) == 1)
{
result = parse(response);
accepted = true;
}
}
}
catch (Newtonsoft.Json.JsonException ex)
{
error = "KV matchmaking response: " + ex.GetType().Name;
}
if (accepted)
{
success(result);
}
else
{
failure(error);
}
}
private static GetMatchResult ReadKVMatch(JObject response)
{
var result = new GetMatchResult
{
MatchId = response["matchId"]?.ToObject<string>()
};
var members = response["members"];
if (members == null || members.Type == JTokenType.Null)
{
return result;
}
if (members is not JArray array)
{
throw new Newtonsoft.Json.JsonSerializationException("Invalid match members.");
}
result.Members = new List<MatchmakingPlayerWithTeamAssignment>();
foreach (var token in array)
{
if (token is not JObject member || member["playerId"]?.Type != JTokenType.String ||
member["attributes"] is not JObject attributes)
{
throw new Newtonsoft.Json.JsonSerializationException("Invalid match member.");
}
result.Members.Add(new MatchmakingPlayerWithTeamAssignment
{
Entity = new EntityKey
{
Id = member["playerId"].ToObject<string>()
},
Attributes = new MatchmakingPlayerAttributes
{
DataObject = attributes
}
});
}
return result;
}
#endregion Matchmaking

View File

@@ -1,4 +1,4 @@
// EventCubeMeltManager.cs
// EventCubeMeltManager.cs
// 锻刀大赛
using System;
@@ -171,33 +171,10 @@ public class EventCubeMeltManager
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;}

View File

@@ -7,7 +7,6 @@ using GameCore;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using UniRx;
using UnityEngine;
@@ -25,52 +24,6 @@ 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
IPlayerSaveStore saveStore;
Tables _tables;
[Header("RELATED OBJECT SETTINGS")]
@@ -762,25 +715,9 @@ 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();
//播放抛竿动画
@@ -791,18 +728,12 @@ 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();
@@ -855,8 +786,8 @@ public partial class FishingBehaviorB : MonoBehaviour
{
GContext.Publish(new ConditionTypeEvent(ConditionType.ConsumeEnergy, energy));
}
var success = (await saveStore.WriteAndConfirmAsync("NewPlayerDataDic",
Newtonsoft.Json.JsonConvert.SerializeObject(commonDatas))).Confirmed;
var success = await saveStore.SyncDataImmidiate("NewPlayerDataDic",
Newtonsoft.Json.JsonConvert.SerializeObject(commonDatas));
Debug.Log($"[FishBehaviourB::ConsumeBaitOnPlayfab] {success}");
}
@@ -1004,9 +935,6 @@ 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));
@@ -1017,12 +945,9 @@ public partial class FishingBehaviorB : MonoBehaviour
var fishEffect = _effectDic["fx_fishing_splashloopwaiting"];
float z = WaitingZ();
float timer = Mathf.Lerp(fishingBehaviorConf.waitingLineDrawMinTime, fishingBehaviorConf.waitingLineDrawMaxTime, Random.value);
var waitingTween=_bait.transform.DOLocalMoveZ(z, timer).OnUpdate(
_bait.transform.DOLocalMoveZ(z, timer).OnUpdate(
(() =>
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVFishingCurrent()) return;
#endif
flyEndPosTrue = _bait.transform.position;
if (fishEffect)
{
@@ -1031,9 +956,6 @@ public partial class FishingBehaviorB : MonoBehaviour
fishEffect.transform.position = vector;
}
})); ;
#if UNITY_EDITOR && !SERVICE_HOST
TrackKVCastTween(waitingTween);
#endif
StartCoroutine(WaitForFishBite(timer));
}
IEnumerator PlayWaitingFx()
@@ -1056,9 +978,6 @@ 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);
@@ -1068,9 +987,6 @@ 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)
{
@@ -1194,27 +1110,18 @@ 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);
var castX=_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime);
var castZ=_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve);
_bait.transform.DOMoveX(0, fishingBehaviorConf.flyToWaterTime);
_bait.transform.DOMoveZ(fishingBehaviorConf.flyDistance, fishingBehaviorConf.flyToWaterTime).SetEase(fishingBehaviorConf.flyForwardCurve);
_cameraAnimator.SetTrigger("_waiting");
var castY=_bait.transform.DOMoveY(_waterHeight, fishingBehaviorConf.flyToWaterTime)
_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");
@@ -1224,25 +1131,16 @@ 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);
//鱼漂位置
@@ -2023,9 +1921,6 @@ 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)
@@ -2438,9 +2333,6 @@ public partial class FishingBehaviorB : MonoBehaviour
private void OnDisable()
{
#if UNITY_EDITOR && !SERVICE_HOST
CancelKVFishing("disabled");
#endif
CloseAllEffect();
}
public void CloseAllEffect()
@@ -2464,9 +2356,6 @@ 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)

View File

@@ -522,22 +522,6 @@ 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)
@@ -639,9 +623,6 @@ 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);
@@ -651,9 +632,6 @@ 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();
@@ -1064,10 +1042,6 @@ 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();
@@ -1245,34 +1219,6 @@ 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)
{
@@ -1351,9 +1297,6 @@ 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);
}
}
@@ -1803,15 +1746,6 @@ 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)
@@ -1852,9 +1786,6 @@ public class FishingUpdateSys : MonoBehaviour
}
}
await Awaiters.Seconds(0.2f);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.RewardCanAdvance) return;
#endif
lockCombo = false;
}
}
@@ -3199,10 +3130,6 @@ public class FishingUpdateSys : MonoBehaviour
}
private void OnDisable()
{
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"controller disabled");
#endif
StopSoundCombo();
StopRodAudio();
StopNotReeling();
@@ -3249,10 +3176,6 @@ 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;

View File

@@ -27,25 +27,10 @@ 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;

View File

@@ -0,0 +1,267 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using asap.core;
using tysdk;
using System.Linq;
using game.PlayerDataStorage;
namespace game
{
public class AggKVUserService : KVUserService
{
private bool channelLoginRunning;
protected override string NewPlayerName
{
get
{
if (TYSdkFacade.TYAccountInfo == null)
{
return base.NewPlayerName;
}
if (TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwFacebook
|| TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwGoogle)
{
if (!string.IsNullOrEmpty(TYSdkFacade.TYAccountInfo.userName))
{
return TYSdkFacade.TYAccountInfo.userName;
}
}
return base.NewPlayerName;
}
}
protected override string NewPlayerAvatar
{
get
{
if (TYSdkFacade.TYAccountInfo == null)
{
return base.NewPlayerAvatar;
}
if (TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwFacebook
/*|| TYSdkFacade.TYAccountInfo.channel == EAccoutType.hwGoogle*/)
{
if (!string.IsNullOrEmpty(TYSdkFacade.TYAccountInfo.avatar))
{
return TYSdkFacade.TYAccountInfo.avatar;
}
}
return base.NewPlayerAvatar;
}
}
public override async Task<ELoginStep> Login(Action<int> progress, bool tryLoginByToken = true)
{
if (channelLoginRunning || LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
channelLoginRunning = true;
try
{
if (IsLogin)
{
progress?.Invoke(3);
return ELoginStep.Done;
}
UnityEngine.Debug.Log("AggUserService Login");
LoginInfo loginInfo = new LoginInfo();
if (!TYSdkFacade.IsLoggedIn)
{
loginInfo = await TYSdkFacade.Instance.LoginBySns();
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
if (!loginInfo.isSuccess && tryLoginByToken)
{
loginInfo = await TYSdkFacade.Instance.LoginByToken();
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
}
if (!loginInfo.isSuccess)
{
var evt = new LoginTypeEvt();
GContext.Publish(evt);
var loginType = await evt.loginType.Task;
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
loginInfo = await TYSdkFacade.Instance.Login(loginType);
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Sdk;
}
}
}
else
{
loginInfo.isSuccess = true;
loginInfo.userId = TYSdkFacade.TYAccountInfo.userId;
}
if (!loginInfo.isSuccess)
{
return ELoginStep.Sdk;
}
progress.Invoke(1);
CustomId = loginInfo.StrUserId;
var step = await base.Login(step => progress?.Invoke(step + 1));
if (LoginLifetime.IsCancellationRequested)
{
return ELoginStep.Api;
}
if (step != ELoginStep.Done)
{
return step;
}
if (ChannelManager.IsRustoreStore)
{
GEvent.SetProp("sdk_channel", "ru_store");
}
GEvent.SetProps(new Dictionary<string, object>()
{
{"pf_id", UserId},
{"CreateTime",CreateTime.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss")}
});
GEvent.SetUserProps(new Dictionary<string, object>()
{
{"pf_id", UserId},
{"CreateTime",CreateTime.ToString("yyyy-MM-dd HH:mm:ss")}
});
AvatarToKV(TYSdkFacade.TYAccountInfo);
GEvent.OnLogin(UserId, CustomId);
if (config.Get<bool>("NewlyCreated", false))
{
using (var e = GEvent.CompleteRegistrationEvent())
{
e.AddContent("login_type", TYSdkFacade.TYAccountInfo.channel.ToString());
}
GEvent.SetProps(new Dictionary<string, object>()
{
{"vip_level", 0}
});
GEvent.SetUserProps(new Dictionary<string, object>()
{
{"vip_level", 0},
{"total_consumption", 0}
});
if (AFHelper.Instance.GetDLReferer(out var pid, out var uid))
{
UnityEngine.Debug.Log("[AggUserService] deeplink referer:" + pid + " " + uid);
using (var e = GEvent.GameEvent("deeplink_invited"))
{
e.AddContent("referrer_uid", uid);
e.AddContent("referrer_pid", pid);
}
var refererData = new Dictionary<string, object>()
{
{"refererPid", pid},
{"refererUid", uid},
{"playerUid", CustomId}
};
_ = customServerMgr.CustomServerPost("friend/RecoreReferer",
Newtonsoft.Json.JsonConvert.SerializeObject(refererData));
}
}
using (var e = GEvent.LoginEvent())
{
e.AddContent("login_type", TYSdkFacade.TYAccountInfo.channel.ToString());
e.AddContent("display_name", DisplayName);
e.AddContent("avatar_url", AvatarUrl);
e.AddContent("linkedAccout", string.Join('#', TYSdkFacade.TYAccountInfo.linkedAccout));
}
return step;
}
finally
{
channelLoginRunning = false;
}
}
public override void Logout()
{
TYSdkFacade.Instance.Logout();
base.Logout();
}
public override async Task<bool> GetLinkState(EAccoutType accoutType)
{
return await TYSdkFacade.Instance.LinkCheck(accoutType);
}
public override async Task<(int code, int userid)> LinkAccount(EAccoutType accoutType)
{
var linkResult = await TYSdkFacade.Instance.LinkAccount(accoutType);
if (linkResult.code == 0 && !LoginLifetime.IsCancellationRequested)
{
var _accountInfo = TYSdkFacade.TYAccountInfo;
if (_accountInfo != null)
{
AvatarToKV(_accountInfo);
if (_accountInfo.channel == EAccoutType.hwFacebook)
{
_ = UpdateAvatarUrl(_accountInfo.avatar);
}
}
}
return (linkResult.code, linkResult.userId);
}
protected override void InitData(IDictionary<string, string> data)
{
if (data.TryGetValue(EAccoutType.hwGoogle.ToString(), out var google) && google == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwGoogle);
}
if (data.TryGetValue(EAccoutType.hwFacebook.ToString(), out var facebook) && facebook == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.hwFacebook);
}
if (data.TryGetValue(EAccoutType.Apple.ToString(), out var apple) && apple == "1")
{
TYSdkFacade.TYAccountInfo.linkedAccout.Add(EAccoutType.Apple);
}
if (TYSdkFacade.TYAccountInfo.channel != EAccoutType.hwGuest && TYSdkFacade.TYAccountInfo.channel != EAccoutType.none)
{
var channel = TYSdkFacade.TYAccountInfo.channel.ToString();
if (!data.ContainsKey(channel))
{
data.Add(channel, "1");
SaveStore.Write(channel, "1").RequireAccepted();
}
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f3774b22ff7e4fd894180c42bed01400

View File

@@ -13,17 +13,6 @@ 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
@@ -56,15 +45,6 @@ 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;
@@ -190,9 +170,8 @@ namespace game
public override void Logout()
{
base.Logout();
if(IsControlledKVInstance) return;
TYSdkFacade.Instance.Logout();
base.Logout();
}
public override async Task<bool> GetLinkState(EAccoutType accoutType)
@@ -218,25 +197,8 @@ 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);

View File

@@ -1,4 +1,4 @@
using asap.core;
using asap.core;
using cfg;
using GameCore;
using System;
@@ -67,19 +67,33 @@ namespace game
}
internal bool HasFormalPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner) =>
requiresFormalKV && ReferenceEquals(promptOwner, owner);
internal void ReleaseFailedPromptOwner(PlayerDataStorage.PlayerKVGameSessionHandle owner)
{
if (promptOwner == null)
{
return;
}
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerDataStorage.PlayerKVSyncStatus.Closed)
{
throw DefaultSettingService.PromptFailure("IsOpenClubRed: failed owner has not closed.");
}
promptOwner = null;
LastPromptEdit = null;
}
internal void RequirePromptOwner()
{
if (promptOwner != null) ReadPrompt();
else if (requiresFormalKV)
throw DefaultSettingService.PromptFailure("IsOpenClubRed: formal owner not handed off.");
}
PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt()
private PlayerDataStorage.PlayerKVKeyReadResult ReadPrompt()
{
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
var read = KVMgr.ReadKV(promptOwner, "IsOpenClubRed");
if (!read.Success)
{
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {read.Error}");
}
return read;
}
// END B01 fixed prompt owner
@@ -101,25 +115,10 @@ namespace game
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;
@@ -203,20 +202,24 @@ namespace game
public void SetOpenClubRed()
{
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null)
{
var read = ReadPrompt();
// M14: any present string means read, including "0" and the empty string.
if (read.Found != true)
ReadPrompt();
if (IsOpenClubRed)
{
IsOpenClubRed = false;
SetClubRed();
LastPromptEdit = KVMgr.WriteKV(promptOwner, "IsOpenClubRed", "1");
if (!LastPromptEdit.Accepted)
{
throw DefaultSettingService.PromptFailure($"IsOpenClubRed owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
}
}
RequirePromptOwner();
IsOpenClubRed = false;
SetClubRed();
return;
}
if (IsOpenClubRed)

View File

@@ -0,0 +1,6 @@
namespace game
{
public sealed class DevKVUserService : KVUserService
{
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5ccd01c3a579486180804cefdbc73f9d

View File

@@ -1,4 +1,4 @@
using System;
using System;
public interface IFootPrintService
{
public EFootPrint FootPrint { get; }
@@ -11,27 +11,14 @@ public interface IFootPrintService
// ReSharper disable once ClassNeverInstantiated.Global
public class FootPrintService : IFootPrintService
{
[asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
[asap.core.Inject]
public game.PlayerDataStorage.IPlayerSaveStore SaveStore
{
get;
set;
}
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)
{
@@ -41,15 +28,13 @@ public class FootPrintService : IFootPrintService
public void UploadData()
{
_kvCompleteFootPrint |= unchecked((uint)(int)_footPrint);
var data = _kvCompleteFootPrint;
var data = (ulong)_footPrint;
SaveStore.Write("FootPrint", data.ToString()).RequireAccepted();
}
public void LoadData(string data)
{
_kvCompleteFootPrint = ulong.Parse(data);
_footPrint = unchecked((EFootPrint)(int)(uint)_kvCompleteFootPrint);
_footPrint = (EFootPrint) ulong.Parse(data);
}
public bool HasFootPrint(EFootPrint state)

View File

@@ -48,6 +48,32 @@ namespace game
public int Code { get; set; }
public string Msg { get; set; }
public BFriendInfo? Info { get; set; }
public KvPublicProfile KvProfile
{
get;
set;
}
}
public class KvPublicProfile
{
public string DisplayName
{
get;
set;
}
public string AvatarUrl
{
get;
set;
}
public int? Level
{
get;
set;
}
}
public class GetHomePageReq

View File

@@ -142,7 +142,9 @@ namespace game
}
};
var result = await PlayFabClientAsyncAPI.GetFriendsListAsync(getFriendsListRequest);
var result = userService is KVUserService
? await GetKVFriendsListAsync()
: await PlayFabClientAsyncAPI.GetFriendsListAsync(getFriendsListRequest);
var list = result?.Friends;
if (list != null)
{
@@ -214,6 +216,57 @@ namespace game
#endif
}
}
private async Task<GetFriendsListResult> GetKVFriendsListAsync()
{
var json = await customServerMgr.FriendRequest("GetFriendsList", new { });
if (string.IsNullOrEmpty(json))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<GetInvitedListResp>(json);
if (response == null || response.Code != 1 || response.Friends == null)
{
return null;
}
var friends = new List<FriendInfo>();
foreach (var serialized in response.Friends)
{
if (string.IsNullOrEmpty(serialized))
{
return null;
}
var item = Newtonsoft.Json.JsonConvert.DeserializeObject<KvFriendItem>(serialized);
if (item == null)
{
return null;
}
friends.Add(new FriendInfo { FriendPlayFabId = item.PlayFabId, Profile = item.Profile });
}
return new GetFriendsListResult { Friends = friends };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV friends response failed: " + ex.GetType().Name);
return null;
}
}
private sealed class KvFriendItem
{
public string PlayFabId
{
get;
set;
}
public PlayerProfileModel Profile
{
get;
set;
}
}
#endregion
#region 邀请好友

View File

@@ -40,7 +40,6 @@ public class LocalNotificationService : ILocalNotificationService
DefaultSettingService promptSettings;
Task initialization;
Task opening;
internal bool HasKVPromptOwner => promptSettings != null;
public LocalNotificationService()
{
_notificationList = GContext.container.Resolve<cfg.Tables>().TbNotification.DataList;

View File

@@ -11,33 +11,49 @@ using game.PlayerDataStorage;
using GameCore;
using tysdk;
using UnityEngine;
using Newtonsoft.Json;
using YiDun.Protector;
#if UNITY_ANDROID
using TapjoyUnity.Internal;
#endif
namespace game
{
// One service belongs to one login lifetime. A revoked instance is never rebound.
public sealed class KVUserService : IUserService, IKVUserLoadReceiver, IKVUserLifecycle
// Failed login attempts may retry; explicit logout ends this service lifetime.
public class KVUserService : IUserService, IKVUserLifecycle
{
[Inject] public ICustomServerMgr customServerMgr { get; set; }
[Inject] public IPlayerSaveStore SaveStore { get; set; }
readonly PlayerKVRuntimeSelection selection;
readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
readonly CancellationTokenSource lifetime = new CancellationTokenSource();
readonly Dictionary<string, PlayInfo> profiles = new Dictionary<string, PlayInfo>();
readonly Dictionary<string, int> levels = new Dictionary<string, int>();
PlayerKVGameLoadCoordinator coordinator;
PlayerKVGameSessionHandle handle;
PlayerKVInitializationIntents intents;
bool attempted, loading, closing, published, promptsBound;
Task closeTask;
long epoch;
string playerId;
uint internalId;
[Inject]
public IConfig config { get; set; }
[Inject]
public ICustomServerMgr customServerMgr { get; set; }
[Inject]
public IPlayerSaveStore SaveStore { get; set; }
//
private readonly PlayerKVRuntimeSelection selection;
//
private readonly Func<CookieContainer, HttpMessageHandler> handlerFactory;
private readonly CancellationTokenSource lifetime = new();
private readonly Dictionary<string, PlayInfo> profiles = new();
private readonly Dictionary<string, int> levels = new();
private PlayerKVGameLoadCoordinator coordinator;
private PlayerKVGameSessionHandle handle;
private bool loading, closing, published, promptsBound;
private PlayerKVGameSessionHandle failedHandle;
private int mmTestValue;
private Task closeTask;
private long epoch;
private string playerId;
private uint internalId;
public string CustomId { get; set; }
public string UserId => IsAuthorized ? playerId : null;
public string UserId => KVHandoffReady ? playerId : null;
public string ApiPlayerId => UserId;
public uint InternalId => IsAuthorized ? internalId : 0;
public bool IsLogin => IsAuthorized;
public bool IsAuthorized => published && KVHandoffReady;
public uint InternalId => KVHandoffReady ? internalId : 0;
public bool IsLogin => KVHandoffReady && playerId != null;
public bool IsAuthorized => published && InternalId != 0 && KVHandoffReady;
public CancellationToken LoginLifetime => lifetime.Token;
public DateTime LastLoginTime { get; private set; }
public DateTime LoginTime { get; private set; }
@@ -47,7 +63,6 @@ namespace game
public string Language { get; private set; }
public string ContinentCode { get; private set; }
public Dictionary<string, string> EAccoutUrl { get; private set; } = new Dictionary<string, string>();
internal int KVTapJoyBalance { get; private set; }
internal PlayerKVGameLoadError KVLastError { get; private set; }
internal bool KVHandoffReady => !closing && handle != null && coordinator != null &&
coordinator.TryGetCurrentGameHandle(epoch, out var current) && ReferenceEquals(current, handle);
@@ -57,88 +72,315 @@ namespace game
public KVUserService() : this(SubBoostrap.RuntimeSelection, jar => new HttpClientHandler
{ CookieContainer = jar, UseCookies = true, AllowAutoRedirect = false }) { }
internal KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
private KVUserService(PlayerKVRuntimeSelection selection, Func<CookieContainer, HttpMessageHandler> handlerFactory)
{
this.selection = selection;
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
Application.quitting += Logout;
Application.quitting += OnApplicationQuitting;
}
public async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
protected virtual string NewPlayerName => $"Angler#{CustomId}";
protected virtual string NewPlayerAvatar => string.Empty;
protected virtual void InitData(IDictionary<string, string> data)
{
if (attempted || closing || selection == null || !selection.IsKV ||
!selection.TryAdmitPlayer(CustomId, out var canonicalId))
{ KVLastError = PlayerKVGameLoadError.Input; return ELoginStep.Api; }
// Set before the first await: duplicate calls cannot authenticate or subscribe twice.
attempted = true;
}
public virtual async Task<ELoginStep> Login(Action<int> progress, bool tyrLoginByToken = true)
{
if (loading || closing)
{
return ELoginStep.Api;
}
if (IsLogin)
{
progress?.Invoke(2);
return ELoginStep.Done;
}
if (selection == null || !selection.IsKV || !selection.TryAdmitPlayer(CustomId, out var canonicalId))
{
KVLastError = PlayerKVGameLoadError.Input;
return ELoginStep.Api;
}
loading = true;
var requestedId = CustomId;
bool successEventStarted = false;
IChatService chat = null;
try
{
if (customServerMgr is not CustomServerMgr server ||
server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
throw new InvalidOperationException("Formal KV transport required.");
loading = true;
var transaction = PlayerKVGameplayBindings.Bind(this);
coordinator = new PlayerKVGameLoadCoordinator(transaction,
() => loading && !closing && !published && handle == null,
() => { }, () => intents?.Count ?? 0, server.BindKVForGame, server.ClearKVForGame);
server.KVUnauthorized += OnUnauthorized;
var identity = new PlayerKVIdentitySnapshot(null, false, PlayerKVIdentityChannel.none,
Array.Empty<PlayerKVIdentityChannel>());
var snapshot = PlayerKVGameplayBindings.Snapshot(DateTime.UtcNow, "formal-v1");
var capability = PlayerKVGameLoadCapability.CreateForGame(identity, snapshot, selection.Api,
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory);
var result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
() => !closing && CustomId == requestedId);
KVLastError = result.Error;
if (result.State != PlayerKVGameLoadState.GameLoaded || closing || CustomId != requestedId ||
result.Account == null || result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
!coordinator.TryGetCurrentGameHandle(result.Epoch, out var original))
{ Logout(); return ELoginStep.Api; }
epoch = result.Epoch;
handle = original;
if (SaveStore is not KVPlayerSaveStore store) throw new InvalidOperationException("KV save store required.");
store.Bind(original, LoginLifetime);
PrepareProfile(result.Account);
BindPromptServices(original);
// Chat subscribes before the unique success event. No SDK or analytics initialization here.
GContext.container.Resolve<IChatService>().WaitingForLogin();
if (!KVHandoffReady || CustomId != requestedId) throw new InvalidOperationException("Stale KV login.");
server.EnableKVBusiness(epoch);
CustomServerMgr server;
KVPlayerSaveStore store;
PlayerKVGameLoadResult result;
PlayerKVGameSessionHandle original;
try
{
if (!await ReleaseFailedLoginAsync() || closing)
{
return ELoginStep.Api;
}
server = customServerMgr as CustomServerMgr;
if (server == null || server.ApiBaseUri.AbsoluteUri != PlayerKVRuntimeSelection.FormalApiUrl)
{
throw new InvalidOperationException("Formal KV transport required.");
}
coordinator = new PlayerKVGameLoadCoordinator(
() => loading && !closing && !published && handle == null,
server.BindKVForGame, server.ClearKVForGame);
server.KVUnauthorized += OnUnauthorized;
var capability = PlayerKVGameLoadCapability.CreateForGame(selection.Api,
Application.persistentDataPath, handlerFactory, handlerFactory, handlerFactory, prepareOriginalComparison: true);
result = await coordinator.LoadForGameAsync(canonicalId, capability, LoginLifetime,
() => !closing && CustomId == requestedId);
KVLastError = result.Error;
if (result.State != PlayerKVGameLoadState.PreparedForGame || result.Data == null ||
closing || CustomId != requestedId || result.Account == null ||
result.Account.PlayerId != canonicalId || result.Account.Epoch != result.Epoch ||
!coordinator.TryGetCurrentGameHandle(result.Epoch, out original))
{
await FailLoginAsync();
return ELoginStep.Api;
}
epoch = result.Epoch;
handle = original;
store = SaveStore as KVPlayerSaveStore;
if (store == null)
{
throw new InvalidOperationException("KV save store required.");
}
store.Bind(original, LoginLifetime);
}
catch (Exception ex)
{
KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV preparation failed: " + ex.GetType().Name);
await FailLoginAsync();
return ELoginStep.Api;
}
await PrepareProfileAsync(result.Account);
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
playerId = result.Account.PlayerId;
internalId = result.Account.InternalId;
ZZTimeHelper.AdjustTime(LoginTime);
ZZTimeHelper.WathForcusChange();
if (!original.CompareDatas(ZZTimeHelper.UtcNow().Ticks).Success)
{
KVLastError = PlayerKVGameLoadError.Apply;
await FailLoginAsync();
return ELoginStep.Api;
}
var merged = original.ReadLocalSnapshot();
if (!merged.Success)
{
KVLastError = PlayerKVGameLoadError.Apply;
await FailLoginAsync();
return ELoginStep.Api;
}
KVMgr.Bind(store);
InitializePlayerData(merged.Data.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal));
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
SetMMTestValue();
try
{
BindPromptServices(original);
}
catch (Exception ex)
{
KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV prompt binding failed: " + ex.GetType().Name);
await FailLoginAsync();
return ELoginStep.Api;
}
chat = GContext.container.Resolve<IChatService>();
chat.WaitingForLogin();
OnYinDunLogin();
if (!KVHandoffReady || CustomId != requestedId)
{
await FailLoginAsync();
return ELoginStep.Api;
}
server.EnableKVBusiness(epoch);
published = true;
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId });
if (!IsAuthorized) return ELoginStep.Api;
_ = RunSaveScheduleAsync(store);
successEventStarted = true;
try
{
GContext.Publish(new EvtAPISvrLoginSuccess { playerId = playerId, internalId = internalId });
}
catch (Exception ex)
{
// Original LoginApiServer catches subscriber errors and removes API authorization.
Debug.LogError(ex);
internalId = 0;
published = false;
server.DisableKVBusiness();
}
if (closing)
{
return ELoginStep.Api;
}
InitTapJoy();
progress?.Invoke(2);
return ELoginStep.Done;
}
catch (Exception ex)
{
if (successEventStarted)
{
throw;
}
KVLastError = PlayerKVGameLoadError.Apply;
Debug.LogError("KV login failed: " + ex.GetType().Name);
Logout();
return ELoginStep.Api;
try
{
(chat as IDisposable)?.Dispose();
}
catch (Exception cleanupError)
{
Debug.LogError("KV chat cleanup failed: " + cleanupError);
}
try
{
await FailLoginAsync();
}
catch (Exception cleanupError)
{
Debug.LogError("KV login cleanup failed: " + cleanupError);
}
throw;
}
finally
{
loading = false;
}
finally { loading = false; }
}
void PrepareProfile(PlayerKVAccountDescriptor account)
private void InitializePlayerData(Dictionary<string, string> data)
{
mmTestValue = 0;
if (!data.ContainsKey("MMTest"))
{
if (config.Get<bool>("NewlyCreated", false))
{
mmTestValue = -1;
}
else
{
SaveStore.Write("MMTest", "MMFailParam#0").RequireAccepted();
}
}
else
{
var parts = data["MMTest"].Split('#');
if (parts.Length > 1 && parts[0] == "MMFailParam")
{
int.TryParse(parts[1], out var value);
mmTestValue = value;
}
else
{
mmTestValue = 0;
SaveStore.Write("MMTest", "MMFailParam#0").RequireAccepted();
}
}
GContext.container.Resolve<PlayerData>().LoadPlayerData(data);
InitData(data);
}
private async Task PrepareProfileAsync(PlayerKVAccountDescriptor account)
{
if (account.InternalId == 0 || account.CreateTimeUtc == DateTime.MinValue || account.ServerTimeUtc == DateTime.MinValue)
{
throw new InvalidOperationException("Authenticated account metadata required.");
}
CreateTime = account.CreateTimeUtc;
LoginTime = account.ServerTimeUtc;
config.Set("NewlyCreated", account.NewlyCreated);
var previous = Read("LastLoginTimeUtc");
LastLoginTime = ParseLastLoginTime(previous.Value, previous.Found.Value);
DisplayName = Read("DisplayName").Value;
if (string.IsNullOrEmpty(DisplayName)) DisplayName = GetDefaultName(account.PlayerId);
AvatarUrl = Read("AvatarUrl").Value ?? string.Empty;
Language = Read("Language").Value;
if (string.IsNullOrEmpty(Language)) Language = GContext.container.Resolve<ISettingService>().Lang;
var continent = Read("ContinentCode").Value;
ContinentCode = new[] { "AF", "AN", "AS", "EU", "NA", "OC", "SA" }.Contains(continent) ? continent : null;
if (string.IsNullOrEmpty(DisplayName))
{
var name = await ChangeDisplayName(NewPlayerName);
LoginLifetime.ThrowIfCancellationRequested();
if (!KVHandoffReady)
{
throw new InvalidOperationException("Stale KV profile.");
}
DisplayName = name;
}
if (string.IsNullOrEmpty(AvatarUrl))
{
await UpdateAvatarUrl(NewPlayerAvatar);
}
await UpdateLanguage(GContext.container.Resolve<ISettingService>().Lang);
LoginLifetime.ThrowIfCancellationRequested();
}
private async Task FailLoginAsync()
{
if (closing)
{
return;
}
if (closeTask != null)
{
await ReleaseFailedLoginAsync();
return;
}
failedHandle = handle;
handle = null;
playerId = null;
internalId = 0;
published = false;
DisplayName = AvatarUrl = Language = ContinentCode = null;
ClearPlayInfos();
if (customServerMgr is CustomServerMgr server)
{
server.KVUnauthorized -= OnUnauthorized;
server.DisableKVBusiness();
}
closeTask = coordinator?.Revoke() ?? Task.CompletedTask;
await ReleaseFailedLoginAsync();
}
private async Task<bool> ReleaseFailedLoginAsync()
{
if (closeTask == null)
{
return true;
}
var status = await (coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready));
if (closing || status != PlayerKVShutdownStatus.Ready || closeTask.Status != TaskStatus.RanToCompletion)
{
KVLastError = status == PlayerKVShutdownStatus.Failed || closeTask.IsFaulted ?
PlayerKVGameLoadError.PreviousCloseFailed : PlayerKVGameLoadError.PreviousClosePending;
return false;
}
if (failedHandle != null)
{
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
(SaveStore as KVPlayerSaveStore)?.ReleaseFailedBinding(failedHandle);
(GContext.container.Resolve<ISettingService>() as DefaultSettingService)?.ReleaseFailedPromptOwner(failedHandle);
GContext.container.Resolve<ClubService>().ReleaseFailedPromptOwner(failedHandle);
}
promptsBound = false;
failedHandle = null;
closeTask = null;
coordinator = null;
epoch = 0;
return true;
}
internal static DateTime ParseLastLoginTime(string value, bool found)
@@ -185,7 +427,31 @@ namespace game
if (version == customServerMgr.SessionVersion) Logout();
}
public void Logout() => ObserveClose(CloseKVForValidationAsync());
private void OnApplicationQuitting()
{
var original = SaveStore as KVPlayerSaveStore;
try
{
KVMgr.Stop(original);
if (original != null && original.IsCurrent)
{
_ = original.SyncDataAsync();
}
}
catch (Exception ex)
{
Debug.LogError("KV quit save failed: " + ex.GetType().Name);
}
finally
{
Logout();
}
}
public virtual void Logout()
{
ObserveClose(CloseKVForValidationAsync());
}
async void ObserveClose(Task task)
{
try { await task; }
@@ -193,14 +459,21 @@ namespace game
}
internal Task CloseKVForValidationAsync()
{
if (closeTask != null) return closeTask;
if (closing)
{
return closeTask ?? Task.CompletedTask;
}
closing = true;
published = false;
KVMgr.Unbind(SaveStore as KVPlayerSaveStore);
(SaveStore as KVPlayerSaveStore)?.Dispose();
playerId = null; internalId = 0; handle = null; epoch = 0;
playerId = null;
internalId = 0;
handle = null;
epoch = 0;
DisplayName = AvatarUrl = Language = ContinentCode = null;
ClearPlayInfos();
Application.quitting -= Logout;
Application.quitting -= OnApplicationQuitting;
if (customServerMgr is CustomServerMgr server)
{
server.KVUnauthorized -= OnUnauthorized;
@@ -216,74 +489,244 @@ namespace game
coordinator?.ObserveQuitAsync() ?? Task.FromResult(PlayerKVShutdownStatus.Ready);
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
void IKVUserLoadReceiver.ResetForKVLoad()
{
if ((!loading && !closing) || published || handle != null)
throw new InvalidOperationException("KV identity must be unpublished during load/reset.");
intents?.Discard(); intents = null;
EAccoutUrl = new Dictionary<string, string>(); KVTapJoyBalance = 0;
}
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state, long loadEpoch, IReadOnlyDictionary<string, string> raw)
{
if (!loading || closing || published || handle != null || state == null || raw == null)
throw new InvalidOperationException("KV load receiver is not current.");
intents = new PlayerKVInitializationIntents(loadEpoch, state.Channel);
foreach (var pair in state.Normalizations) intents.Add(loadEpoch, pair.Key, pair.Value);
EAccoutUrl = state.AccountUrls.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
KVTapJoyBalance = state.TapJoyBalance;
}
void IKVUserLoadReceiver.DiscardKVIntents() => intents?.Discard();
async Task SaveProfile(string key, string value)
private async Task<bool> SaveProfile(string key, string value)
{
var original = handle;
var store = SaveStore;
if (!IsAuthorized || store == null) throw new InvalidOperationException("KV identity unavailable.");
var result = await store.WriteAndConfirmAsync(key, value, LoginLifetime);
if (!IsAuthorized || !ReferenceEquals(store, SaveStore) || !store.IsCurrent || !result.Confirmed)
throw new InvalidOperationException("KV profile save was not confirmed.");
}
async Task RunSaveScheduleAsync(IPlayerSaveStore store)
{
if ((!IsLogin && !loading) || !KVHandoffReady || store == null)
{
return false;
}
try
{
while (IsAuthorized && store.IsCurrent)
{
await Task.Delay(TimeSpan.FromSeconds(40), LoginLifetime);
if (!IsAuthorized || !store.IsCurrent) return;
var result = await store.FlushAsync(LoginLifetime);
if (!result.Confirmed && !LoginLifetime.IsCancellationRequested)
Debug.LogWarning("KV scheduled save: " + result.Status);
}
var result = await store.SyncDataImmidiate(key, value);
return ReferenceEquals(original, handle) && KVHandoffReady &&
ReferenceEquals(store, SaveStore) && store.IsCurrent && result;
}
catch (OperationCanceledException)
{
return false;
}
catch (OperationCanceledException) { }
catch (Exception ex) { Debug.LogError("KV save scheduler failed: " + ex.GetType().Name); }
}
public async Task<string> ChangeDisplayName(string newName)
{
await SaveProfile("DisplayName", newName);
DisplayName = newName; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return newName;
var originName = string.IsNullOrEmpty(DisplayName) ? NewPlayerName : DisplayName;
if (!await SaveProfile("DisplayName", newName))
{
return originName;
}
DisplayName = newName;
GContext.Publish(new ChangeAvatarEvent());
UpdateProfileName();
return newName;
}
public async Task<string> UpdateAvatarUrl(string url)
{
await SaveProfile("AvatarUrl", url);
AvatarUrl = url; GContext.Publish(new ChangeAvatarEvent()); UpdateProfile(); return url;
var originUrl = AvatarUrl;
Debug.Log("UpdateAvatarUrlAsync originUrl:" + originUrl + " url " + url);
if (string.IsNullOrEmpty(url))
{
return originUrl;
}
if (url == originUrl)
{
return originUrl;
}
if (!await SaveProfile("AvatarUrl", url))
{
return originUrl;
}
AvatarUrl = url;
GContext.Publish(new ChangeAvatarEvent());
UpdateProfileAvatar();
return url;
}
public async Task<string> UpdateLanguage(string code)
{ await SaveProfile("Language", code); Language = code; return code; }
{
if (Language == code)
{
return Language;
}
if (await SaveProfile("Language", code.Replace('_', '-')))
{
Language = code;
}
return Language;
}
public void GMMMT(int value)
{
mmTestValue = value;
SaveStore.Write("MMTest", $"MMFailParam#{mmTestValue}").RequireAccepted();
SetMMTestValue();
}
private void SetMMTestValue()
{
string ab = GContext.container.Resolve<MMTService>().Init(mmTestValue);
GEvent.SetUserProp("MMTestValue", ab, true);
Debug.Log($"UserId:{UserId} MMTestValue :0b{Convert.ToString(mmTestValue, 2)} MMTestValueString:{ab}");
}
protected void AvatarToKV(TYSdkFacade.AccountInfo accountInfo)
{
if (accountInfo != null &&
(accountInfo.channel == EAccoutType.hwFacebook || accountInfo.channel == EAccoutType.hwGoogle) &&
!string.IsNullOrEmpty(accountInfo.avatar))
{
if (EAccoutUrl == null)
{
EAccoutUrl = new Dictionary<string, string>();
}
EAccoutUrl[accountInfo.channel.ToString()] = accountInfo.avatar;
SaveStore.Write("EAccoutUrl", JsonConvert.SerializeObject(EAccoutUrl)).RequireAccepted();
}
}
private void InitTapJoy()
{
#if UNITY_ANDROID
var lastBalanceStr = Read("TapJoy_Diamond").Value ?? "";
Debug.Log($"InitTapJoy -> {lastBalanceStr}");
var lastBalance = int.TryParse(lastBalanceStr, out var temp) ? temp : 0;
var tapjoyObject = GameObject.FindObjectOfType<TapjoyComponent>();
if (tapjoyObject && tapjoyObject.gameObject && !tapjoyObject.GetComponent<TapjoyHelper>())
{
var tapjoyHelper = tapjoyObject.gameObject.AddComponent<TapjoyHelper>();
GameObject.DontDestroyOnLoad(tapjoyHelper);
tapjoyHelper.Init(lastBalance, OnEarnCurrencyName, OnBalance);
}
return;
void OnEarnCurrencyName(string currencyName, int amount)
{
Debug.Log($"OnEarnCurrencyName -> {currencyName} {amount}");
// var amount = (balance - lastBalance);
var diamonds = new ItemData { id = 1005, count = amount };
diamonds.curCount = (ulong)GContext.container.Resolve<PlayerItemData>().GetItemCount(diamonds.id);
GContext.container.Resolve<PlayerItemData>().AddItem(diamonds, null);
GContext.Publish(new ShowData(new List<ItemData> { diamonds }, RewardType.NormalOne));
GContext.Publish(new ShowData());
#if AGG
using (var e = GEvent.GameEvent("tapjoy"))
{
e.AddContent("item_id", diamonds.id)
.AddContent("item_count", diamonds.count);
}
#endif
}
void OnBalance(string currencyName, int balance)
{
Debug.Log($"OnBalance -> {currencyName} {balance}");
// const string tapjoyKey = "TapJoy_Diamond";
// var lastBalanceStr = PlayFabMgr.Instance.GetLocalData(tapjoyKey) ?? "";
// var lastBalance = int.TryParse(lastBalanceStr, out var temp) ? temp : 0;
{
// PlayFabMgr.Instance.UpdateUserDataValue(tapjoyKey, balance.ToString());
// if (balance > lastBalance)
// {
// var amount = (balance - lastBalance);
// var diamonds = new ItemData { id = 1005, count = amount };
// diamonds.curCount = (ulong)GContext.container.Resolve<PlayerItemData>().GetItemCount(diamonds.id);
// GContext.container.Resolve<PlayerItemData>().AddItem(diamonds);
// GContext.Publish(new ShowData(new List<ItemData> { diamonds }, RewardType.NormalOne));
// // GContext.Publish(new ShowData(diamonds));
// GContext.Publish(new ShowData());
// }
}
}
#endif
}
private async void OnYinDunLogin()
{
// 开发模式下,先不启用了
if (config != null && (config.Get("DEBUG", "0") == "1" || config.Get("GM", "0") == "1"))
{
return;
}
var userService = GContext.container.Resolve<IUserService>();
var yiDunManager = GContext.container.Resolve<YiDunManager>();
var roleName = userService.DisplayName;
var roleId = userService.UserId;
var roleAccount = userService.CustomId;
var versionData = new
{
GameVersion = Application.version,
AssetVersion = config.Get(GConstant.K_AssetVersion, string.Empty),
#if UNITY_ANDROID
Channel = GConstant.Yidun_GooglePlayChannel
#elif UNITY_IOS
Channel = GConstant.Yidun_IOSChannel
#endif
};
Debug.Log($"JsonUtility.ToJson(versionData) => {JsonConvert.SerializeObject(versionData)}");
var businessId = GConstant.Yidun_AndroidBusinessId;
#if UNITY_IOS
businessId = GConstant.Yidun_IOSBusinessId;
#endif
#if (UNITY_ANDROID || UNITY_IOS || UNITY_IPHONE) && !UNITY_EDITOR
yiDunManager.SetRoleInfo(businessId, roleId, roleName, roleAccount, null, -1, JsonConvert.SerializeObject(versionData));
yiDunManager.PostRiskCheck();
#endif
}
public double CreateTotalSeconds() => (ZZTimeHelper.UtcNow() - CreateTime).TotalSeconds;
// The non-AGG channel has no account-link operation; never manufacture a successful binding.
public Task<(int code, int userid)> LinkAccount(EAccoutType type) => Task.FromResult((1, 0));
public Task<bool> GetLinkState(EAccoutType type) => Task.FromResult(false);
public virtual async Task<(int code, int userid)> LinkAccount(EAccoutType type)
{
await Awaiters.NextFrame;
return (1, 0);
}
public virtual async Task<bool> GetLinkState(EAccoutType type)
{
await Awaiters.NextFrame;
return false;
}
public string GetDefaultName(string id) => "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
public void SetPlayerLv(string id, int level) => levels[id] = id == UserId ? GContext.container.Resolve<PlayerData>().lv : level;
public int GetPlayerLv(string id) => levels.TryGetValue(id, out var level) ? level : 1;
public Task<(string, int)> GetPlayLv(string id)
public async Task<(string, int)> GetPlayLv(string id)
{
var level = id == UserId && IsAuthorized ? (int)GContext.container.Resolve<PlayerData>().lv :
id != null && levels.TryGetValue(id, out var cachedLevel) ? cachedLevel : -1;
return level >= 0 ? Task.FromResult((id, level)) :
Task.FromException<(string, int)>(new InvalidOperationException("Public player level has not been loaded."));
if (level >= 0)
{
return (id, level);
}
if (id != UserId && !string.IsNullOrEmpty(id) && !levels.ContainsKey(id))
{
var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id });
if (!string.IsNullOrEmpty(json))
{
SearchFriendResp response = null;
try
{
response = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json);
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV player level response failed: " + ex.GetType().Name);
}
if (response != null && response.Code > 0 && response.KvProfile != null)
{
level = response.KvProfile.Level ?? 1;
if (level >= 0)
{
levels[id] = level;
return (id, level);
}
}
}
}
throw new InvalidOperationException("Public player level has not been loaded.");
}
public PlayInfo GetPlayInfo(string id)
{
@@ -296,7 +739,7 @@ namespace game
}
return profile;
}
async void SetPlayInfo(string id, PlayInfo profile)
private async void SetPlayInfo(string id, PlayInfo profile)
{
if (id == UserId)
{
@@ -306,27 +749,63 @@ namespace game
return;
}
var json = await customServerMgr.FriendRequest("SearchFriend", new SearchFriendReq { PlayFabId = id });
if (string.IsNullOrEmpty(json)) return;
if (string.IsNullOrEmpty(json))
{
return;
}
SearchFriendResp result;
try { result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json); }
try
{
result = Newtonsoft.Json.JsonConvert.DeserializeObject<SearchFriendResp>(json);
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogError("KV player profile response failed: " + ex.GetType().Name);
return;
}
if (result != null && result.Code > 0 && result.Info != null)
if (result != null && result.Code > 0 && result.KvProfile != null)
{
if (!string.IsNullOrEmpty(result.Info.DisplayName)) profile.name = result.Info.DisplayName;
profile.avatarUrl = result.Info.AvatarUrl;
if (!string.IsNullOrEmpty(result.KvProfile.DisplayName))
{
profile.name = result.KvProfile.DisplayName;
}
profile.avatarUrl = result.KvProfile.AvatarUrl;
GContext.Publish(new GetClubPlayInfoEvent { id = id, name = profile.name, avatarUrl = profile.avatarUrl });
}
}
public void SetPlayInfo(string id, string name, string avatarUrl)
{
if (string.IsNullOrEmpty(id)) return;
profiles[id] = new PlayInfo { name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name, avatarUrl = avatarUrl };
profiles[id] = new PlayInfo
{
name = string.IsNullOrEmpty(name) ? GetDefaultName(id) : name,
avatarUrl = avatarUrl
};
}
public void ClearPlayInfos() { profiles.Clear(); levels.Clear(); }
private void UpdateProfileName()
{
if (!IsAuthorized)
{
return;
}
PlayerProfile playerProfile = new PlayerProfile();
playerProfile.DisplayName = DisplayName;
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
}
private void UpdateProfileAvatar()
{
if (!IsAuthorized)
{
return;
}
PlayerProfile playerProfile = new PlayerProfile();
playerProfile.Avatar = AvatarUrl;
LoginRequest loginRequest = new LoginRequest() { playerId = ApiPlayerId, playerProfile = playerProfile };
customServerMgr.AccountRequest("UpdateProfile", loginRequest);
}
public void UpdateProfile()
{
if (IsAuthorized) customServerMgr.AccountRequest("UpdateProfile", new LoginRequest { playerId = ApiPlayerId,

View File

@@ -1,11 +0,0 @@
using System.Collections.Generic;
namespace game.PlayerDataStorage
{
internal interface IKVUserLoadReceiver
{
void ResetForKVLoad();
void ReplaceFromKVDecoded(PlayerKVLoginState state, long epoch, IReadOnlyDictionary<string, string> raw);
void DiscardKVIntents();
}
}

View File

@@ -10,9 +10,7 @@ namespace game.PlayerDataStorage
bool IsCurrent { get; }
PlayerSaveReadResult Read(string key);
PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true);
Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default);
Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default);
Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default);
Task<bool> SyncDataImmidiate(string key, string value);
}
public sealed class PlayerSaveReadResult
@@ -50,13 +48,4 @@ namespace game.PlayerDataStorage
}
}
// Buffered and LocalOnly are deliberately different from cloud confirmation.
public enum PlayerSaveStatus { Confirmed, Buffered, LocalOnly, Rejected, Superseded, Unknown, Cancelled }
public sealed class PlayerSaveConfirmation
{
public PlayerSaveStatus Status { get; }
public bool Confirmed => Status == PlayerSaveStatus.Confirmed;
public string Error { get; }
internal PlayerSaveConfirmation(PlayerSaveStatus status, string error = null) { Status = status; Error = error; }
}
}

View File

@@ -0,0 +1,609 @@
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.ExceptionServices;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using GameCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEditor;
namespace game.PlayerDataStorage
{
// Editor-only probes of the existing business methods; no production decoding or reset graph.
internal static class Module02OriginalLoadTests
{
private const BindingFlags InstanceFlags = BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public;
private static bool running;
private static bool ContainsPayload(JToken actual, JToken expected)
{
if (expected is JObject obj)
{
return actual is JObject && obj.Properties().All(p => ContainsPayload(actual[p.Name], p.Value));
}
if (expected is JArray array)
{
return actual is JArray other && array.Count == other.Count && Enumerable.Range(0, array.Count).All(i => ContainsPayload(other[i], array[i]));
}
return JToken.DeepEquals(actual, expected);
}
internal static T Field<T>(object target, string name)
{
return (T)target.GetType().GetField(name, InstanceFlags).GetValue(target);
}
private static void Invoke(object target, string name, params object[] arguments)
{
try
{
target.GetType().GetMethod(name, InstanceFlags).Invoke(target, arguments);
}
catch (TargetInvocationException ex)when (ex.InnerException != null)
{
ExceptionDispatchInfo.Capture(ex.InnerException).Throw();
throw;
}
}
internal static void LoadCore(PlayerData player, IDictionary<string, string> modern)
{
typeof(PlayerData).GetField("NewPlayerDataDic", InstanceFlags).SetValue(player, new Dictionary<string, string>(modern));
Invoke(player, "SetPlayerData");
}
internal static void LoadCore(PlayerData player, object[] slots)
{
LoadCore(player, new Dictionary<string, string> { ["newData"] = string.Join(",", slots.Select(value => Convert.ToString(value, CultureInfo.CurrentCulture))), ["vipL"] = "-1,1" });
}
internal static void LoadGuide(GuideDataCenter guide, IDictionary<string, string> raw)
{
if (raw.TryGetValue("GuideSaveData", out var legacy))
{
guide.GuideSaveDataDic = JsonConvert.DeserializeObject<Dictionary<string, GuideSaveData>>(legacy);
}
if (raw.TryGetValue("GuideSaveDataNew", out var modern))
{
guide.SetData(modern);
}
}
private sealed class ContextAccess : GContext
{
protected override Task SetupAsync()
{
throw new InvalidOperationException("Validation context cannot start gameplay.");
}
internal static void Use(Container value)
{
container = value;
}
internal static IEventAggregator Events { get => eventAggregator; set => eventAggregator = value; }
}
private sealed class MemoryStore : IPlayerSaveStore
{
private readonly Dictionary<string, string> data = new Dictionary<string, string>();
internal readonly List<string> Writes = new List<string>();
public bool IsCurrent => true;
public PlayerSaveReadResult Read(string key)
{
return new PlayerSaveReadResult(data.TryGetValue(key, out var value), value);
}
public PlayerSaveWriteResult Write(string key, string value, bool remoteStore = true)
{
data[key] = value;
Writes.Add(key);
return new PlayerSaveWriteResult(new PlayerSaveReceipt(this, null));
}
public Task<bool> SyncDataImmidiate(string key, string value)
{
Write(key, value);
return Task.FromResult(true);
}
}
private sealed class FaceObserver : IFaceUIService
{
internal readonly List<string> Calls = new List<string>();
public void AddFaceCustomUIData(UIType type, bool first = false)
{
}
public void ShowFaceUI(string value = "")
{
}
public void AddGiftFaceUIForce(int id, UIType type)
{
Calls.Add("force:" + id + ":" + type);
}
public void AddGiftFaceUIOnLogin(int id, UIType type, int priority)
{
}
public void AddGiftFaceUI(int id, UIType type, int priority = 0)
{
Calls.Add("gift:" + id + ":" + type + ":" + priority);
}
public int GetFaceUICount() => Calls.Count;
public void StopFaceUI()
{
}
public void AddPostFaceUI(UIType type)
{
}
public void ShowPostFaceUI()
{
}
public int GetPostFaceUICount() => 0;
public void StopPostFaceUI()
{
}
}
private sealed class Fixture : IDisposable
{
private readonly Container previous = GContext.container;
private readonly IEventAggregator previousEvents = ContextAccess.Events;
private readonly Container isolated = new Container();
private readonly string previousPinataPath = UITypes.PinataPack_PonyPanel.Path;
private readonly string previousPinataName = UITypes.PinataPack_PonyPanel.Name;
private readonly Dictionary<string, UIType> previousUITypes = new Dictionary<string, UIType>(UITypes.UITypesDic);
private readonly object previousRedPoints = typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).GetValue(null);
internal readonly cfg.Tables Tables;
internal readonly PlayerData Player;
internal readonly PlayerFishData Fish;
internal readonly FishingEventData Events;
internal readonly PlayerShopData Shop;
internal readonly GuideDataCenter Guide;
internal readonly BattlePassData Battle;
internal readonly MemoryStore Store = new MemoryStore();
internal readonly FaceObserver Face = new FaceObserver();
internal Fixture()
{
var directory = System.IO.Path.GetFullPath(System.IO.Path.Combine(UnityEngine.Application.dataPath, "../../cfgData"));
Tables = new cfg.Tables(name => SimpleJSON.JSON.Parse(System.IO.File.ReadAllText(System.IO.Path.Combine(directory, name + ".json"))));
ContextAccess.Use(isolated);
ContextAccess.Events = new EventAggregator();
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, new RedPointManager());
Register(Tables);
isolated.RegisterInstance<IFaceUIService>(Face);
Player = Register(new PlayerData(Tables, null) { playerSaveStore = Store });
Register(new SmallGameData(Tables, null, null));
Fish = Register(new PlayerFishData(Tables, null));
Events = Register(new FishingEventData(Tables));
Shop = Register(new PlayerShopData { _tables = Tables, SaveStore = Store });
Register(new BuffDataCenter(Tables));
Register(new CampDataMM(Tables, null));
Register(new AchievementDataManager(Tables));
Register(new FishingBoxData());
Guide = Register(new GuideDataCenter(Tables, Player));
Battle = Register(new BattlePassData(Tables) { playerSaveStore = Store });
Register(new SigninData(Tables));
Register(new TriggerPackData(Tables));
Register(new ClubService(false));
Register(new FishingChallengeManager());
Register(new DataCenter.OfferChainsChestManager());
Register(new DataCenter.OfferDiscoPartyManager());
Register(new DataCenter.EventPartnerGatherManager());
Register(new EventCubeMeltManager());
Register(new EventPackData());
Register(new PiggyBankPackData());
Register(new BargainPackData());
isolated.RegisterInstance<IFootPrintService>(new FootPrintService());
Register(new EventShootingRangeData());
Register(new EventPotionDataManager());
Register(new EventPinballDataManager());
Register(new EventScratchTicketDataManager());
Register(new Script.RuntimeScript.DiggingGameManager());
Register(new PlayerItemData(Tables));
// Original constructor, after its existing dependencies are available.
Register(new EventPinataPackDataManager());
}
private T Register<T>(T instance)
where T : class
{
isolated.RegisterInstance<T>(instance);
return instance;
}
internal T Get<T>()
where T : class
{
return isolated.Resolve<T>();
}
public void Dispose()
{
ContextAccess.Use(previous);
ContextAccess.Events = previousEvents;
typeof(RedPointManager).GetField("instance", BindingFlags.Static | BindingFlags.NonPublic).SetValue(null, previousRedPoints);
UITypes.PinataPack_PonyPanel.SetType(previousPinataPath);
UITypes.PinataPack_PonyPanel.SetName(previousPinataName);
UITypes.UITypesDic.Clear();
foreach (var pair in previousUITypes)
{
UITypes.UITypesDic[pair.Key] = pair.Value;
}
isolated.Dispose();
}
}
internal static string InspectRaw(Dictionary<string, string> raw)
{
using (var fixture = new Fixture())
{
fixture.Player.LoadPlayerData(raw);
return JsonConvert.SerializeObject(new { originalLoad = true, keys = raw.Count, level = (int)fixture.Player.lv, writes = fixture.Store.Writes, faceCalls = fixture.Face.Calls, formalRequests = 0 });
}
}
internal static Task<string> RunAsync(string caseId)
{
if (running || EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isCompiling || EditorApplication.isUpdating || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0 || PlayerKVGameplayLoop.Enabled)
{
throw new InvalidOperationException("Original load validation requires an idle isolated Editor.");
}
running = true;
var previousContainer = GContext.container;
var previousEvents = ContextAccess.Events;
var previousDiff = ZZTimeHelper.timeDiff;
var previousOffset = ZZTimeHelper.LocalTimeOffsetInSec;
var traces = new List<object>();
int assertions = 0;
string failure = null;
void Check(bool condition, string name)
{
assertions++;
if (!condition)
{
throw new InvalidOperationException(name);
}
}
try
{
// Baseline executes the unchanged original method; target executes the KV private common entry.
// The third path deliberately reproduces the former pre-adjust load for a visible counterexample.
foreach (var seconds in new[]
{
-1,
1
}
)
{
foreach (var offset in new[]
{
-3600,
3600
}
)
{
var outputs = new List<(int eventId, DateTime energyTime, int limitedCount)>();
foreach (var path in new[]
{
"original-after-adjust",
"kv-after-adjust",
"previous-before-adjust"
}
)
{
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var time = (cfg.LimitedTime)selected.TimeDefinition;
var globalOffset = offset < 0 ? -9 : 9;
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(time, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(time, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var raw = new Dictionary<string, string>
{
["MMTest"] = "MMFailParam#0",
["EventPackData"] = JsonConvert.SerializeObject(new { currentEventID = selected.ID, purchaseCount = 2, redirectID = selected.RedirectID }),
["LimitedTimeEventDic"] = JsonConvert.SerializeObject(new Dictionary<int, object> { [900001] = new { id = 900001, duration = 2, startTime = boundary.AddHours(-globalOffset).AddMinutes(-2).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture) } })
};
ZZTimeHelper.LocalTimeOffsetInSec = offset;
var desired = boundary.AddSeconds(seconds);
ZZTimeHelper.AdjustTime((path == "previous-before-adjust" ? boundary.AddDays(2) : desired).AddSeconds(-offset));
if (path == "kv-after-adjust")
{
var user = new KVUserService
{
SaveStore = fixture.Store
};
Invoke(user, "InitializePlayerData", raw);
}
else
{
fixture.Player.LoadPlayerData(raw);
}
var eventId = fixture.Get<EventPackData>().CurrentEventID;
var energyTime = Field<DateTime>(fixture.Player, "_lastEnergyRecoveryTime");
var limitedCount = ((System.Collections.IDictionary)Field<object>(fixture.Events, "limitedTimeEventDic")).Count;
outputs.Add((eventId, energyTime, limitedCount));
traces.Add(new { path, seconds, offset, globalOffset, desired, eventId, energyTime, limitedCount, faceCalls = fixture.Face.Calls.ToArray(), writes = fixture.Store.Writes.ToArray() });
Check(fixture.Face.Calls.Count == 0, "M20 original commented UI remains inactive");
}
}
Check(outputs[0].eventId == outputs[1].eventId, "Original and KV activity boundary match");
Check(outputs[0].limitedCount == outputs[1].limitedCount && outputs[0].limitedCount == (seconds < 0 ? 1 : 0), "LimitedTime expiry uses original global offset on both paths");
Check(Math.Abs((outputs[0].energyTime - outputs[1].energyTime).TotalSeconds) < 1, "Original and KV energy clock match");
Check(outputs[0].eventId == (seconds < 0 ? 0 : outputs[2].eventId), "M20 original expiry/reset semantics");
Check(Math.Abs((outputs[2].energyTime - outputs[1].energyTime).TotalHours) > 24, "Old load-before-adjust clock counterexample is real");
}
}
// Existing refresh lifecycle is tested separately; login loading must not invoke it early.
foreach (var globalOffset in new[]
{
-9,
9
}
)
{
foreach (var seconds in new[]
{
-1,
1
}
)
{
using (var fixture = new Fixture())
{
typeof(cfg.GlobalConfig).GetProperty("GlobalTimeOffset").SetValue(Field<cfg.GlobalConfig>(fixture.Tables.TbGlobalConfig, "_data"), globalOffset);
var eventData = fixture.Tables.TbFishingEvent.DataList.First(e => fixture.Tables.TbBattlePassMain.GetOrDefault(e.RedirectID) != null);
fixture.Battle.BattlePassRecord.ID = eventData.ID;
using (var provider = new BattlePassDataProvider(fixture.Tables, fixture.Battle, fixture.Get<PlayerItemData>()))
{
GContext.container.RegisterInstance(provider);
ZZTimeHelper.LocalTimeOffsetInSec = 0;
var desired = new DateTime(2026, 9, 27, 0, 0, 0, DateTimeKind.Utc).AddHours(globalOffset).AddSeconds(seconds);
ZZTimeHelper.AdjustTime(desired);
fixture.Battle.RefreshDaiyTask();
fixture.Battle.RefreshWeeklyTask();
var offsetNow = ZZTimeHelper.UtcNow().UtcNowOffset();
Check(fixture.Battle.BattleTask.DailyTaskEndTime == ConvertTools.GetDateTimeYMD(offsetNow).AddDays(1), "BP original daily lifecycle boundary");
Check(fixture.Battle.BattleTask.WeeklyTaskEndTime == ConvertTools.GetDateTimeSunDay(offsetNow).AddDays(1), "BP original weekly lifecycle boundary");
Check(fixture.Store.Writes.Contains("BattlePassRecordTask"), "BP original lifecycle saves through existing store");
traces.Add(new { path = "original-BP-refresh-lifecycle", globalOffset, seconds, desired, dailyEnd = fixture.Battle.BattleTask.DailyTaskEndTime, weeklyEnd = fixture.Battle.BattleTask.WeeklyTaskEndTime, writes = fixture.Store.Writes.ToArray() });
}
}
}
}
using (var fixture = new Fixture())
{
var boundary = new DateTime(2026, 9, 23, 0, 0, 0, DateTimeKind.Utc);
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
ZZTimeHelper.LocalTimeOffsetInSec = 0;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var selected = fixture.Tables.TbFishingEvent.DataList.First(e => e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPackManager.GetOrDefault(e.RedirectID)?.PackType == 3);
var limit = (cfg.LimitedTime)selected.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(limit, boundary.AddDays(-1).ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(limit, boundary.ToString("yyyy/MM/dd HH:mm:ss", CultureInfo.InvariantCulture));
var bargain = fixture.Get<BargainPackData>();
var pack = fixture.Tables.TbEventPackManager[selected.RedirectID];
var packId = pack.VIPPackList[0][0];
var saved = new GeneralEventPackData(eventID: selected.ID, redirectID: selected.RedirectID, vip: 0, purchaseCount: 0, isTriggered: true, savingProgress: int.MaxValue, discountLvl: fixture.Tables.TbSpecialPack[packId].IAPID.Count - 1, inflation: 1);
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1 && fixture.Face.Calls[0].StartsWith("force:" + selected.ID + ":"), "M22 original full active bargain registers original UI once");
saved.isTriggered = false;
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 untriggered bargain does not register UI");
saved.isTriggered = true;
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
bargain.LoadData(saved);
Check(fixture.Face.Calls.Count == 1, "M22 expired bargain does not register UI");
traces.Add(new { path = "original-bargain-side-effects", calls = fixture.Face.Calls.ToArray() });
fixture.Face.Calls.Clear();
var pinataEvent = fixture.Tables.TbFishingEvent.DataList.First(e => e.Type == 9 && e.SubType == 6 && e.TimeDefinition is cfg.LimitedTime && fixture.Tables.TbEventPinataPackMain.GetOrDefault(e.RedirectID) != null);
foreach (var ev in fixture.Tables.TbFishingEvent.DataList.Where(e => e.Type == 9 && e.SubType == 6))
{
if (ev.TimeDefinition is cfg.LimitedTime other)
{
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(other, boundary.AddDays(-2).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(other, boundary.AddDays(-1).ToString("s"));
}
}
var pinataTime = (cfg.LimitedTime)pinataEvent.TimeDefinition;
typeof(cfg.LimitedTime).GetProperty("StartTime").SetValue(pinataTime, boundary.AddDays(-1).ToString("s"));
typeof(cfg.LimitedTime).GetProperty("EndTime").SetValue(pinataTime, boundary.ToString("s"));
typeof(cfg.FishingEvent).GetProperty("ConditionList").SetValue(pinataEvent, new List<cfg.EventCondition> { new cfg.EventCondition(cfg.ConditionType.AccountLevel, new List<string> { "1" }, "") });
ZZTimeHelper.AdjustTime(boundary.AddSeconds(-1));
var pinata = fixture.Get<EventPinataPackDataManager>();
pinata.LoadData("{\"EventID\":0,\"PinataMainID\":0,\"PurchasedCount\":7}");
var target = fixture.Get<EventPinataPackData>();
Check(target.EventID == pinataEvent.ID && target.PurchasedCount == 0 && fixture.Face.Calls.Count == 1, "M29 original changed event refreshes purchase state and registers UI");
pinata.LoadData(JsonConvert.SerializeObject(new { target.EventID, target.PinataMainID, PurchasedCount = 1 }));
Check(target.PurchasedCount == 1 && fixture.Face.Calls.Count == 1, "M29 same event preserves purchase count without repeated UI");
ZZTimeHelper.AdjustTime(boundary.AddSeconds(1));
pinata.GetFreshPinataPackData();
Check(fixture.Face.Calls.Count == 1, "M29 expired event does not add UI");
traces.Add(new { path = "original-pinata-side-effects", calls = fixture.Face.Calls.ToArray(), target.EventID, target.PinataMainID, target.PurchasedCount });
}
using (var fixture = new Fixture())
{
var raw = PlayerKVEditorValidationHost.FullRaw(fixture.Tables, true);
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 3 && (ulong)fixture.Player.gold == 999 && (int)fixture.Player.Energy == 5, "Original newData wins over defaults");
Check(fixture.Shop.Voucher == 6 && fixture.Shop.shopPackDailyBuyCount["x"] == 1, "Shop numeric dictionaries");
Check(fixture.Events.TurntableCoupon == 7 && fixture.Events.WheelLevelCount[5] == 6, "Event original defaults and saved counts");
Check(fixture.Guide.GuideSaveDataDic["A"].guideSubIndex == 3 && fixture.Guide.GuideSaveDataDic["A"].isFinish, "Guide original modern format");
Check(fixture.Battle.BattlePassRecord.Level == 2 && fixture.Battle.BattleTask.dic_dailyTasks[9] == 10, "BP legacy values");
var small = fixture.Get<SmallGameData>();
Check(small.HeistGameCount == 12 && small.BombGameCount == 13 && small.FishHuntGameCount == 14, "Original small game slots");
Check(Field<Dictionary<string, int>>(fixture.Get<CampDataMM>(), "CampDataDic")["CampId"] == 205, "Original Camp dictionary");
Check(Field<Dictionary<int, FishBuffTimeData>>(fixture.Get<BuffDataCenter>(), "mapBuffDataList")[7].buffID == 3, "Original buff deserialization");
Check(fixture.Get<FishingBoxData>().GetString() == "1,2", "Original modern box replaces legacy");
Check(fixture.Get<SigninData>().monthlySignEvent.index == 4 && fixture.Get<SigninData>().dailySignEvent.index == 2, "Original monthly sign history");
Check(fixture.Get<TriggerPackData>().triggerPackList[0].vipLevel == 4, "Original trigger pack history");
foreach (var sample in new[]
{
("FishingChallengeData", (object)fixture.Get<FishingChallengeManager>().fishingChallengeData),
("OfferChainsChestData", (object)fixture.Get<DataCenter.OfferChainsChestManager>().OfferChainsData),
("OfferDiscoPartyManager", (object)fixture.Get<DataCenter.OfferDiscoPartyManager>().OfferDiscoPartyData),
("EventPartnerGatherData", (object)fixture.Get<DataCenter.EventPartnerGatherManager>().GatherData),
("EventCubeMeltData", (object)fixture.Get<EventCubeMeltManager>().EventData),
("epdk", (object)fixture.Get<EventPotionDataManager>().PotionData),
("estdk", (object)fixture.Get<EventScratchTicketDataManager>().ScratchTicketData)
}
)
{
var expected = JToken.Parse(raw[sample.Item1]);
if (sample.Item1 == "OfferDiscoPartyManager")
{
((JObject)expected).Remove("ChestReward");
((JObject)expected).Remove("Finished");
}
Check(ContainsPayload(JToken.FromObject(sample.Item2), expected), "Original numeric/history payload: " + sample.Item1);
}
var pinball = fixture.Get<EventPinballDataManager>();
Check(pinball.PinballData.Token == 11 && pinball.RewardList.SequenceEqual(fixture.Tables.TbEventPinballReward.DataList.Select(r => (float)r.SpinPoint)), "Original Pinball initializes reward configuration");
Check(!Field<bool>(pinball, "m_IsSimulatorMode"), "Original Pinball exits simulator mode");
Check(fixture.Events.ThanksGivingPackData.EventId == 1 && fixture.Events.ThanksGivingPackData.ChainProgress == 4 && fixture.Events.ChainPackWithProgressMigrationData.EventId == 5 && fixture.Events.ChainPackWithProgressMigrationData.ChainProgress == 8, "Two original progress keys remain independent");
var achievement = fixture.Get<AchievementDataManager>();
achievement.LoadAchievementDataToObject("{\"0\":\"7|18446744073709551615|9\"}");
Check(Field<Dictionary<cfg.ConditionType, AchievementData>>(achievement, "_achievementDatas")[(cfg.ConditionType)0].Target == ulong.MaxValue, "Original achievement preserves ulong maximum");
Check(fixture.Get<ClubService>().IsOpenClubRed == false, "Club key existence rule");
Check(fixture.Get<EventPackData>().PurchaseCount == 2, "Unknown EventPack preserves parsed data");
Check(fixture.Get<BargainPackData>().PurchaseCount == 11, "Bargain original data");
Check(fixture.Get<PiggyBankPackData>().PurchaseCount == 11, "Piggy original data");
Check(fixture.Get<IFootPrintService>().FootPrint == (EFootPrint)ulong.Parse(raw["FootPrint"]), "Original FootPrint enum conversion");
var model = fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel;
fixture.Player.LoadPlayerData(new Dictionary<string, string>());
Check(ReferenceEquals(model, fixture.Get<Script.RuntimeScript.DiggingGameManager>().DataModel), "Digging original existing model retained");
Check(fixture.Shop.shopPackDailyBuyCount["x"] == 1 && fixture.Get<BargainPackData>().PurchaseCount == 11, "Retry missing keys retains original existing objects");
traces.Add(new { path = "full-original-and-missing-key-retry", keys = raw.Keys.ToArray(), writes = fixture.Store.Writes.ToArray(), faceCalls = fixture.Face.Calls.ToArray() });
}
using (var fixture = new Fixture())
{
var rod = fixture.Tables.TbGlobalConfig.InitRodID;
foreach (var count in new[]
{
19,
20
}
)
{
var slots = new object[]
{
9,
23UL,
0,
-4,
5,
6,
-999,
new DateTime(2026, 1, 1),
0,
8,
7UL,
0,
0,
0,
0,
0,
2,
3,
4,
99
}.Take(count).ToArray();
var raw = new Dictionary<string, string>
{
["Lv"] = "2",
["Energy"] = "10",
["EquipRodID"] = rod.ToString(),
["NewPlayerDataDic"] = JsonConvert.SerializeObject(new Dictionary<string, string> { ["LV"] = "4", ["newData"] = string.Join(",", slots), ["vipL"] = "-1,1" })
};
fixture.Player.LoadPlayerData(raw);
Check((int)fixture.Player.lv == 9 && fixture.Player.Energy == 0 && fixture.Player.equipRodID == rod, "19 and 20 slots original override/clamp/rod fallback");
}
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14");
Check(fixture.Shop.CouponData.RedirectId == 0 && !fixture.Shop.CouponData.IsBought, "Seven-field coupon history");
fixture.Shop.CouponData.LoadData("2026,9,23,1,2,3,14,15,1");
Check(fixture.Shop.CouponData.RedirectId == 15 && fixture.Shop.CouponData.IsBought, "Nine-field coupon history");
var compressed = fixture.Battle.BattlePassRecord.SerializeObject() + "#" + fixture.Battle.BattleTask.SerializeObject();
fixture.Battle.InitData(new Dictionary<string, string> { ["BattlePassRecordTask"] = compressed, ["BattlePassRecord"] = "bad JSON", ["BattleTask"] = "bad JSON" });
Check(fixture.Battle.BattlePassRecord != null && fixture.Battle.BattleTask != null, "Compressed BP takes precedence without parsing conflicting legacy");
var realFish = fixture.Tables.TbFishData.DataList.First(f => fixture.Tables.TbMapData.DataMap.ContainsKey(f.AreaID));
fixture.Fish.InitData(new Dictionary<string, string> { ["Map_5_" + realFish.AreaID] = JsonConvert.SerializeObject(new Dictionary<int, string> { [realFish.ID] = "0|7|2|3|True" }), ["Map_" + realFish.AreaID] = "bad legacy JSON" });
var parsedFish = Field<Dictionary<int, Dictionary<int, FishWeightData>>>(fixture.Fish, "mapFishData")[realFish.AreaID][realFish.ID];
Check(parsedFish.TotalNum == 3 && parsedFish.NewFish, "Map_5 takes precedence over broken legacy map with original weight correction");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["GuideSaveData"] = "bad JSON", ["GuideSaveDataNew"] = "K|bad|1", ["LimitedTimeEventDic"] = "bad JSON", ["epdk"] = "", ["epbdk"] = "bad JSON", ["estdk"] = "bad JSON", ["Lv"] = "17" });
Check((int)fixture.Player.lv == 17 && fixture.Guide.GuideSaveDataDic["K"].guideSubIndex == 0, "Original local catches continue and Guide TryParse default");
Exception thrown = null;
try
{
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["IsFix"] = "invalid-bool" });
}
catch (FormatException ex)
{
thrown = ex;
}
Check(thrown != null && thrown.StackTrace.Contains("InitData"), "Original external parse exception is not Schema rejection");
fixture.Player.LoadPlayerData(new Dictionary<string, string> { ["Lv"] = "19" });
Check((int)fixture.Player.lv == 19, "Original load can retry after failure without uniform reset");
}
}
catch (Exception ex)
{
failure = ex.ToString();
}
finally
{
ZZTimeHelper.timeDiff = previousDiff;
ZZTimeHelper.LocalTimeOffsetInSec = previousOffset;
ContextAccess.Use(previousContainer);
ContextAccess.Events = previousEvents;
running = false;
}
var restored = ReferenceEquals(GContext.container, previousContainer) && ReferenceEquals(ContextAccess.Events, previousEvents) && ZZTimeHelper.timeDiff == previousDiff && ZZTimeHelper.LocalTimeOffsetInSec == previousOffset;
return Task.FromResult(JsonConvert.SerializeObject(new { success = failure == null && restored, caseId, assertions, traces, failure, restored, formalRequests = 0, baseline = "f40a816cc414fc4f30d67626d060d90fdc5eaca3 original business methods (source comparison required)", playerFabNetworkLoginExecuted = false }));
}
}
}
#endif

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 8cda3d2b981ada043af2162ca707d8bf
guid: 04d2914963285d343b5a0e9503b59390
MonoImporter:
externalObjects: {}
serializedVersion: 2

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
using System.Net.Http;
@@ -98,7 +99,8 @@ namespace game.PlayerDataStorage
if (mutation) return new PlayerKVResult(PlayerKVError.None, status);
var text = await response.Content.ReadAsStringAsync();
token.ThrowIfCancellationRequested();
return new PlayerKVResult(PlayerKVError.None, status, ParseData(text));
var data = ParseData(text, out var updatedAtUtc);
return new PlayerKVResult(PlayerKVError.None, status, data, updatedAtUtc: updatedAtUtc);
}
}
}
@@ -125,18 +127,50 @@ namespace game.PlayerDataStorage
}
}
static Dictionary<string, string> ParseData(string text)
private static Dictionary<string, string> ParseData(string text, out Dictionary<string, DateTime> updatedAtUtc)
{
updatedAtUtc = null;
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.");
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.");
if (property.Value.Type != JTokenType.String)
{
throw new JsonReaderException("KV value must be a string.");
}
result.Add(property.Name, property.Value.Value<string>());
}
var timestamps = body.Property("updatedAtUtc");
if (timestamps != null)
{
if (!(timestamps.Value is JObject times) || times.Count != result.Count)
{
throw new JsonReaderException("Invalid KV timestamp metadata.");
}
updatedAtUtc = new Dictionary<string, DateTime>(StringComparer.Ordinal);
var formats = new[] { "yyyy-MM-dd'T'HH:mm:ss'Z'", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF'Z'" };
foreach (var property in times.Properties())
{
if (!result.ContainsKey(property.Name) || property.Value.Type != JTokenType.String)
{
throw new JsonReaderException("Invalid KV timestamp entry.");
}
var value = property.Value.Value<string>();
if ((value.Length != 20 && (value.Length < 22 || value.Length > 28)) ||
!DateTime.TryParseExact(value, formats, CultureInfo.InvariantCulture,
DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var timestamp))
{
throw new JsonReaderException("KV timestamp must be UTC ISO 8601.");
}
updatedAtUtc.Add(property.Name, timestamp);
}
}
return result;
}
}

View File

@@ -7,12 +7,9 @@ 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.
// Prebuild distinct transports with one cookie jar, then transfer each owned handler once.
internal sealed class PlayerKVGameLoadCapability
{
internal PlayerKVIdentitySnapshot Identity { get; }
internal PlayerKVSchemaSnapshot Snapshot { get; }
internal Uri Api { get; }
internal string CacheRoot { get; }
internal int LoginObserveMs { get; }
@@ -20,47 +17,59 @@ namespace game.PlayerDataStorage
internal bool RequireExistingArchive { get; }
internal Func<int,CancellationToken,Task> Delay { get; }
internal PlayerKVControlledLoadCapability LeaseCapability { get; private set; }
readonly PlayerKVControlledLoadCapability auth,kv,lease;
private readonly Func<CookieContainer, HttpMessageHandler> auth;
private readonly Func<CookieContainer, HttpMessageHandler> kv;
private readonly Func<CookieContainer, HttpMessageHandler> lease;
private readonly bool prepareOriginalComparison;
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,
bool requireExistingArchive=false)
private PlayerKVGameLoadCapability(Uri api, string cacheRoot,
int loginMs, int quitMs, Func<int, CancellationToken, Task> delay,
Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
Func<CookieContainer, HttpMessageHandler> lease, bool requireExistingArchive = false, bool prepareOriginalComparison = false)
{
Identity=identity ?? throw new ArgumentNullException(nameof(identity)); Snapshot=snapshot ?? throw new ArgumentNullException(nameof(snapshot));
if(api==null || !api.IsAbsoluteUri || (api.Scheme!="http" && api.Scheme!="https") || !string.IsNullOrEmpty(api.UserInfo) ||
// Preserve the former wrappers' null-check order, diagnostic and configuration precedence.
this.auth = auth ?? throw new ArgumentNullException("leaseHandler");
this.kv = kv ?? throw new ArgumentNullException("leaseHandler");
this.lease = lease ?? throw new ArgumentNullException("leaseHandler");
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;
RequireExistingArchive=requireExistingArchive;
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;
RequireExistingArchive = requireExistingArchive;
this.prepareOriginalComparison = prepareOriginalComparison;
}
internal static PlayerKVGameLoadCapability CreateForGame(PlayerKVIdentitySnapshot identity,PlayerKVSchemaSnapshot snapshot,
Uri api,string cacheRoot,Func<CookieContainer,HttpMessageHandler> auth,Func<CookieContainer,HttpMessageHandler> kv,
Func<CookieContainer,HttpMessageHandler> lease)
internal static PlayerKVGameLoadCapability CreateForGame(Uri api, string cacheRoot,
Func<CookieContainer, HttpMessageHandler> auth, Func<CookieContainer, HttpMessageHandler> kv,
Func<CookieContainer, HttpMessageHandler> lease, bool prepareOriginalComparison = false)
{
if(api==null || api.AbsoluteUri!="http://s101.local:18080/")
throw new ArgumentException("Formal KV endpoint mismatch.",nameof(api));
var capability=new PlayerKVGameLoadCapability(identity,snapshot,api,cacheRoot,10000,5000,null,
PlayerKVControlledLoadCapability.CreateForGame(auth),PlayerKVControlledLoadCapability.CreateForGame(kv),
PlayerKVControlledLoadCapability.CreateForGame(lease),true);
capability.LeaseCapability=PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
if (api == null || api.AbsoluteUri != "http://s101.local:18080/")
{
throw new ArgumentException("Formal KV endpoint mismatch.", nameof(api));
}
var capability = new PlayerKVGameLoadCapability(api, cacheRoot, 10000, 5000, null, auth, kv, lease, true, prepareOriginalComparison);
capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForGame(capability.TakeLease);
return capability;
}
#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)
internal static PlayerKVGameLoadCapability CreateForValidation(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);
var capability = new PlayerKVGameLoadCapability(api, cacheRoot, loginMs, quitMs, delay, auth, kv, lease);
capability.LeaseCapability = PlayerKVControlledLoadCapability.CreateForValidation(capability.TakeLease);
return capability;
}
#endif
@@ -68,26 +77,48 @@ namespace game.PlayerDataStorage
{
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));
()=>new S101AccountSession(Api,CreateAuthentication),()=>new PlayerKVGameSessionBridge(TakeKV, prepareOriginalComparison));
}
HttpMessageHandler CreateAuthentication(CookieContainer jar)
private HttpMessageHandler CreateAuthentication(CookieContainer jar)
{
lock(gate)
lock (gate)
{
if(cookies!=null) throw new InvalidOperationException("Repeated authentication factory.");
cookies=jar; HttpMessageHandler authentication=null;
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(ShareHandlerGraph(authentication,pendingKV,pendingLease))
authentication = PlayerKVControlledLoadCapability.CreateHandler(auth, jar, LeaseCapability.IsFormal);
pendingKV = PlayerKVControlledLoadCapability.CreateHandler(kv, jar, LeaseCapability.IsFormal);
pendingLease = PlayerKVControlledLoadCapability.CreateHandler(lease, jar, LeaseCapability.IsFormal);
if (ShareHandlerGraph(authentication, 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 { }
try
{
authentication?.Dispose();
}
catch (Exception ex)
{
cleanupFailure = ex;
failedHandlers.Add(authentication);
}
try
{
CloseUnused();
}
catch
{
}
throw;
}
}

View File

@@ -1,969 +0,0 @@
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; }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 35b5e1a67b2043b5aa3c34a67d2fe8f0

View File

@@ -1,12 +1,34 @@
using System;
using System.Collections.Generic;
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, GameLoaded }
{
None,
BackendNotEnabled,
BackendUnsupported,
Input,
Prepare,
Schema,
Apply,
Stale,
SideEffect,
GateBusy,
PreviousClosePending,
PreviousCloseFailed,
ClosePending,
CloseFailed
}
internal enum PlayerKVGameLoadState
{
Rejected,
PreparedForGame
}
// Preserve stage evidence without retaining a successful Prepare payload on a later load failure.
internal sealed class PlayerKVPrepareEvidence
{
@@ -20,232 +42,415 @@ namespace game.PlayerDataStorage
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; }
{
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 { get; }
internal PlayerKVGameLoadError Error { get; }
internal PlayerKVGameLoadError OriginalError { get; }
internal bool CleanupFailed => Error==PlayerKVGameLoadError.CloseFailed;
internal PlayerKVSchemaVerdict? SchemaVerdict { get; }
internal PlayerKVPrepareEvidence Prepare { get; }
internal long Epoch { get; }
internal PlayerKVAccountDescriptor Account { get; }
internal int ModuleCount { get; }
internal int IntentCount { get; }
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error,long epoch,PlayerKVGameLoadError? original=null,
PlayerKVSchemaVerdict? verdict=null,PlayerKVLoginPrepareResult prepare=null,int modules=0,int intents=0,bool forGame=false,PlayerKVAccountDescriptor account=null)
{ Error=error; OriginalError=original ?? error; SchemaVerdict=verdict; Prepare=prepare==null ? null : new PlayerKVPrepareEvidence(prepare); Epoch=epoch; ModuleCount=modules; IntentCount=intents;
Account=error==PlayerKVGameLoadError.None && forGame ? account : null;
State=error!=PlayerKVGameLoadError.None ? PlayerKVGameLoadState.Rejected : forGame ? PlayerKVGameLoadState.GameLoaded : PlayerKVGameLoadState.LoadedForValidation; }
internal PlayerKVGameLoadState State
{
get;
}
internal PlayerKVGameLoadError Error
{
get;
}
internal PlayerKVGameLoadError OriginalError
{
get;
}
internal bool CleanupFailed => Error == PlayerKVGameLoadError.CloseFailed;
internal PlayerKVPrepareEvidence Prepare
{
get;
}
internal long Epoch
{
get;
}
internal PlayerKVAccountDescriptor Account
{
get;
}
internal IReadOnlyDictionary<string, string> Data
{
get;
}
internal PlayerKVGameLoadResult(PlayerKVGameLoadError error, long epoch, PlayerKVGameLoadError? original = null, PlayerKVLoginPrepareResult prepare = null, PlayerKVAccountDescriptor account = null, IReadOnlyDictionary<string, string> data = null)
{
Error = error;
OriginalError = original ?? error;
Prepare = prepare == null ? null : new PlayerKVPrepareEvidence(prepare);
Epoch = epoch;
Account = error == PlayerKVGameLoadError.None ? account : null;
Data = error == PlayerKVGameLoadError.None ? data : null;
State = error == PlayerKVGameLoadError.None ? PlayerKVGameLoadState.PreparedForGame : PlayerKVGameLoadState.Rejected;
}
}
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;
PlayerKVGameSessionHandle gameHandle;
internal bool LoadedForValidation => loaded && context!=null && context.IsCurrent && guard!=null && guard.IsCurrent && guard.Violations==0;
private readonly SemaphoreSlim loginGate = new SemaphoreSlim(1, 1);
private readonly object closeGate = new object ();
private readonly Func<bool> unstarted;
private readonly Action<PlayerKVAccountLease> bind;
private readonly Func<Task> unbind;
private readonly PlayerKVShutdownRegistry registry;
private readonly int mainThread;
private readonly SynchronizationContext mainContext;
private PlayerKVLoginContext context;
private PlayerKVGameLoadGuard guard;
private PlayerKVGameLoadCapability capability;
private Task closing;
private CancellationTokenRegistration lifetimeRegistration;
private long epoch;
private PlayerKVGameSessionHandle gameHandle;
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)
internal PlayerKVGameLoadCoordinator(Func<bool> unstarted, 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;
this.unstarted = unstarted ?? throw new ArgumentNullException(nameof(unstarted));
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 Task<PlayerKVGameLoadResult> LoadAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
=> LoadCoreAsync(id,next,token,identityCurrent,false);
internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token=default,Func<bool> identityCurrent=null)
=> LoadCoreAsync(id,next,token,identityCurrent,true);
internal bool TryGetCurrentGameHandle(long expectedEpoch,out PlayerKVGameSessionHandle handle)
internal Task<PlayerKVGameLoadResult> LoadForGameAsync(string id, PlayerKVGameLoadCapability next,
CancellationToken token = default, Func<bool> identityCurrent = null)
{
handle=null;
PlayerKVLoginContext owner; PlayerKVGameSessionHandle published;
lock(closeGate)
return LoadCoreAsync(id, next, token, identityCurrent);
}
internal bool TryGetCurrentGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle handle)
{
handle = null;
PlayerKVLoginContext owner;
PlayerKVGameSessionHandle published;
lock (closeGate)
{
if(closing!=null || epoch!=expectedEpoch || gameHandle==null) return false;
owner=context; published=gameHandle;
if (closing != null || epoch != expectedEpoch || gameHandle == null)
{
return false;
}
owner = context;
published = gameHandle;
}
// Never call the external identity predicate while holding a publication monitor.
if(!owner.TryAcquireGameHandle(expectedEpoch,out var original) || !ReferenceEquals(original,published)) return false;
lock(closeGate)
if (!owner.TryAcquireGameHandle(expectedEpoch, out var original) || !ReferenceEquals(original, published))
{
if(closing!=null || epoch!=expectedEpoch || !ReferenceEquals(context,owner) || !ReferenceEquals(gameHandle,published)) return false;
handle=published;
return false;
}
lock (closeGate)
{
if (closing != null || epoch != expectedEpoch || !ReferenceEquals(context, owner) || !ReferenceEquals(gameHandle, published))
{
return false;
}
handle = published;
return true;
}
}
async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id,PlayerKVGameLoadCapability next,CancellationToken token,Func<bool> identityCurrent,bool forGame)
async Task<PlayerKVGameLoadResult> LoadCoreAsync(string id, PlayerKVGameLoadCapability next, CancellationToken token, Func<bool> identityCurrent)
{
if(next==null || Thread.CurrentThread.ManagedThreadId!=mainThread || string.IsNullOrEmpty(id))
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;
}
if (!await EnterGate(next, token))
{
return Result(token.IsCancellationRequested ? PlayerKVGameLoadError.Stale : PlayerKVGameLoadError.GateBusy);
}
PlayerKVLoginPrepareResult prepared = 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);
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())
{
return Result(PlayerKVGameLoadError.Input);
}
if (!PlayerKVGameLoadGuard.TryEnter(out var enteredGuard))
{
return Result(PlayerKVGameLoadError.GateBusy);
}
lifetimeRegistration.Dispose();
lock(closeGate)
lock (closeGate)
{
guard=enteredGuard; capability=next; closing=null; loaded=false; gameHandle=null;
attempt=Interlocked.Increment(ref epoch);
context=next.CreateContext(id,attempt,e=>Interlocked.Read(ref epoch)==e && !token.IsCancellationRequested && (identityCurrent==null || identityCurrent()));
guard = enteredGuard;
capability = next;
closing = null;
gameHandle = null;
attempt = Interlocked.Increment(ref epoch);
context = next.CreateContext(id, attempt, e => Interlocked.Read(ref epoch) == e && !token.IsCancellationRequested && (identityCurrent == null || identityCurrent()));
}
var owner=context;
lifetimeRegistration=token.Register(()=> { _=Revoke(attempt); });
var work=context.PrepareAsync(token);
var cancelled=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using(token.Register(()=>cancelled.TrySetResult(true)))
var owner = context;
lifetimeRegistration = token.Register(() =>
{
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);
if(next.RequireExistingArchive && (context.AccountDescriptor?.NewlyCreated!=false || prepared.Data==null || prepared.Data.Count==0))
return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
var decoded=PlayerKVSaveSchema.Decode(prepared,context.AccountDescriptor,id,attempt,context.IsCurrent,next.Snapshot,next.Snapshot.Version,next.Identity);
verdict=decoded.Verdict;
if(decoded.Data==null) return await Reject(PlayerKVGameLoadError.Schema,attempt,next,prepared,verdict);
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 || !owner.IsCurrent || enteredGuard.Violations!=0) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
var intents=intentCount();
PlayerKVGameSessionHandle original=null;
if(forGame && !owner.TryAcquireGameHandle(attempt,out original)) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
bool published=false;
lock(closeGate)
_ = Revoke(attempt);
});
var work = context.PrepareAsync(token);
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (token.Register(() => cancelled.TrySetResult(true)))
{
if(closing==null && epoch==attempt && !token.IsCancellationRequested && ReferenceEquals(context,owner) && enteredGuard.IsCurrent && enteredGuard.Violations==0)
if (await Task.WhenAny(work, cancelled.Task) != work)
{
if(forGame)
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
prepared = await work;
}
if (!prepared.Prepared)
{
return await Reject(prepared.CleanupFailed ? PlayerKVGameLoadError.CloseFailed : PlayerKVGameLoadError.Prepare, attempt, next, prepared);
}
if (!context.IsCurrent || epoch != attempt)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
if (next.RequireExistingArchive && (context.AccountDescriptor?.NewlyCreated != false || prepared.Data == null || prepared.Data.Count == 0))
{
return await Reject(PlayerKVGameLoadError.Schema, attempt, next, prepared);
}
if (!context.TryAcquireAccountLease(next.LeaseCapability, out var lease))
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
bind(lease);
if (token.IsCancellationRequested || !owner.IsCurrent || enteredGuard.Violations != 0)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
var account = owner.AccountDescriptor;
if (account == null)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
PlayerKVGameSessionHandle original = null;
if (!owner.TryAcquireGameHandle(attempt, out original))
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
bool published = false;
lock (closeGate)
{
if (closing == null && epoch == attempt && !token.IsCancellationRequested && ReferenceEquals(context, owner) && enteredGuard.IsCurrent && enteredGuard.Violations == 0)
{
enteredGuard.Dispose();
// Dispose serializes with Reject; its final count cannot change afterwards.
if (enteredGuard.Violations == 0)
{
enteredGuard.Dispose();
// Dispose serializes with Reject; its final count cannot change afterwards.
if(enteredGuard.Violations==0) { gameHandle=original; published=true; }
gameHandle = original;
published = true;
}
else { loaded=true; published=true; }
}
}
if(!published) return await Reject(PlayerKVGameLoadError.Stale,attempt,next,prepared,verdict);
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None,attempt,verdict:verdict,modules:31,intents:intents,forGame:forGame,account:context.AccountDescriptor);
if (!published)
{
return await Reject(PlayerKVGameLoadError.Stale, attempt, next, prepared);
}
return new PlayerKVGameLoadResult(PlayerKVGameLoadError.None, attempt, account: account, data: prepared.Data);
}
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))
catch
{
var wait=loginGate.WaitAsync(cancellation.Token); var deadline=next.Delay(next.LoginObserveMs,cancellation.Token);
bool acquired=false,accepted=false;
return await Reject(PlayerKVGameLoadError.Apply, attempt, next, prepared);
}
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; }
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) { }
try
{
await wait;
acquired = true;
}
catch (OperationCanceledException)
{
}
return false;
}
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();
if (acquired && !accepted)
{
loginGate.Release();
}
}
}
}
internal Task Revoke(long? expectedEpoch=null)
internal Task Revoke(long? expectedEpoch = null)
{
lock(closeGate)
lock (closeGate)
{
if(expectedEpoch.HasValue && expectedEpoch.Value!=Interlocked.Read(ref epoch)) return Task.CompletedTask;
if(closing!=null) return closing;
loaded=false; gameHandle=null; Interlocked.Increment(ref epoch);
if(context==null && guard==null) return Task.CompletedTask;
var completion=new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
closing=completion.Task;
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);
if (expectedEpoch.HasValue && expectedEpoch.Value != Interlocked.Read(ref epoch))
{
return Task.CompletedTask;
}
if (closing != null)
{
return closing;
}
gameHandle = null;
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 unbind 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);
var detached = unbind();
CompleteClear(detached, cleared);
}
catch (Exception ex)
{
cleared.TrySetException(ex);
}
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);
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)
static async void CompleteClear(Task detached, TaskCompletionSource<bool> cleared)
{
try
{
await Task.WhenAll(drain,clear).ConfigureAwait(false); source?.CloseUnused(); leaseGuard?.Dispose(); completion.TrySetResult(true);
await detached;
cleared.TrySetResult(true);
}
catch (Exception ex)
{
cleared.TrySetException(ex);
}
catch(Exception ex) { completion.TrySetException(ex); }
}
async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original,long attempt,PlayerKVGameLoadCapability source,
PlayerKVLoginPrepareResult prepare,PlayerKVSchemaVerdict? verdict)
static async Task FinishClose(Task drain, Task clear, PlayerKVGameLoadGuard leaseGuard, PlayerKVGameLoadCapability source, TaskCompletionSource<bool> completion)
{
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);
try
{
await Task.WhenAll(drain, clear).ConfigureAwait(false);
source?.CloseUnused();
leaseGuard?.Dispose();
completion.TrySetResult(true);
}
catch (Exception ex)
{
completion.TrySetException(ex);
}
}
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error,Interlocked.Read(ref epoch));
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs,delay:capability?.Delay);
async Task<PlayerKVGameLoadResult> Reject(PlayerKVGameLoadError original, long attempt, PlayerKVGameLoadCapability source, PlayerKVLoginPrepareResult prepare)
{
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, prepare: prepare);
}
PlayerKVGameLoadResult Result(PlayerKVGameLoadError error) => new PlayerKVGameLoadResult(error, Interlocked.Read(ref epoch));
internal Task<PlayerKVShutdownStatus> ObserveQuitAsync() => registry.ObserveAsync(QuitObserveMs, delay: capability?.Delay);
}
}

View File

@@ -1,100 +0,0 @@
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;
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c1ea0dd4977649169863e3c917802188

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Threading;
@@ -87,8 +88,13 @@ namespace game.PlayerDataStorage
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)); }
private readonly bool prepareOriginalComparison;
internal PlayerKVGameSessionBridge(Func<CookieContainer, HttpMessageHandler> handlerFactory, bool prepareOriginalComparison = false)
{
this.handlerFactory = handlerFactory ?? throw new ArgumentNullException(nameof(handlerFactory));
this.prepareOriginalComparison = prepareOriginalComparison;
}
public Task<PlayerKVGameOpenResult> OpenAsync(S101AccountSession session, string cacheRoot,
string playerId, Uri api, long epoch, Func<long, bool> isCurrent, CancellationToken lifetimeToken = default)
@@ -145,7 +151,7 @@ namespace game.PlayerDataStorage
active = slot;
}
slot.Creation = PlayerKVSessionOwner.CreateAsync(session, cacheRoot, playerId, api,
epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory);
epoch, slot.IsCurrent, slot.Lifetime.Token, handlerFactory, prepareOriginalComparison: prepareOriginalComparison);
var opened = await slot.Creation.ConfigureAwait(false);
if (!opened.Success)
{
@@ -261,9 +267,49 @@ namespace game.PlayerDataStorage
? 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 Update(string key, string value, bool remoteStore = true)
{
return Update(key, value, remoteStore, null);
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
return slot.Revoked ? new PlayerKVStateResult(PlayerKVChangeError.Blocked) : Owner.CompareDatas(nowTicks);
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks)
{
return slot.Revoked ? BlockedEdit() : Owner.Update(key, value, remoteStore, updateTicks);
}
internal Task<PlayerKVFlushResult> SyncDataAsync(CancellationToken waitToken = default)
{
if (slot.Revoked)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status));
}
return Owner.SyncDataAsync(waitToken);
}
public PlayerKVEditResult Delete(string key) => slot.Revoked ? BlockedEdit() : Owner.Delete(key);
public Task<bool> SyncDataImmidiate(string key, string value)
{
if (slot.Revoked)
{
return Task.FromResult(false);
}
return Owner.SyncDataImmidiate(key, value);
}
internal Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
if (slot.Revoked)
{
return Task.FromResult(false);
}
return Owner.SyncDataImmidiate(values);
}
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default) => slot.Revoked
? Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, Owner.Status))
: Owner.FlushAsync(waitToken);
@@ -273,4 +319,4 @@ namespace game.PlayerDataStorage
static PlayerKVEditResult BlockedEdit() =>
new PlayerKVEditResult(new PlayerKVStateResult(PlayerKVChangeError.Blocked));
}
}
}

View File

@@ -1,60 +0,0 @@
#if !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,string version="kv-loop")
{
var tables=Require<cfg.Tables>();
return new PlayerKVSchemaSnapshot(version,tables.TbRodData.DataMap.Keys,tables.TbMapData.DataMap.Keys,
Enum.GetNames(typeof(cfg.ConditionType)).ToDictionary(n=>n,n=>(int)Enum.Parse(typeof(cfg.ConditionType),n)),CultureInfo.InvariantCulture,
nowUtc,tables.TbGlobalConfig.InitWheelTicket,
tables.TbEventPackManager.DataMap.ToDictionary(p=>p.Key,p=>p.Value.PackType),
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(IKVUserLoadReceiver user)
{
var player=Require<GameCore.PlayerData>(); var small=Require<GameCore.SmallGameData>(); var fish=Require<GameCore.PlayerFishData>();
var shop=Require<GameCore.PlayerShopData>(); var eventBase=Require<GameCore.FishingEventData>(); var buffs=Require<BuffDataCenter>(); var camp=Require<CampDataMM>();
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.DiscardKVIntents);
}
}
}
#endif

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 7803da4435ea45c28007ff0198c676fb

View File

@@ -1,30 +0,0 @@
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; }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6215961788794950aff0709dbb499793

View File

@@ -58,7 +58,7 @@ namespace game.PlayerDataStorage
internal PlayerKVAccountDescriptor AccountDescriptor =>
Volatile.Read(ref prepared) != 0 && IsCurrent ? accountDescriptor : null;
// Only the coordinator may publish this original handle after Apply and Guard release.
// Only the coordinator may publish this original handle after preparation and Guard release.
internal bool TryAcquireGameHandle(long expectedEpoch, out PlayerKVGameSessionHandle gameHandle)
{
gameHandle = null;
@@ -351,7 +351,13 @@ namespace game.PlayerDataStorage
internal HttpMessageHandler CreateHandler(CookieContainer cookies)
{
var handler = leaseHandler(cookies) ?? throw new InvalidOperationException("Transport handler required.");
return CreateHandler(leaseHandler, cookies, formal);
}
internal static HttpMessageHandler CreateHandler(Func<CookieContainer, HttpMessageHandler> factory,
CookieContainer cookies, bool formal)
{
var handler = factory(cookies) ?? throw new InvalidOperationException("Transport handler required.");
var seen = new HashSet<HttpMessageHandler>();
var node = handler;
while (node != null)
@@ -359,7 +365,9 @@ namespace game.PlayerDataStorage
// 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 (!formal && (node is HttpClientHandler ||
node.GetType().FullName == "System.Net.Http.SocketsHttpHandler"))
{

View File

@@ -1,3 +1,4 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
@@ -15,16 +16,23 @@ namespace game.PlayerDataStorage
public int? StatusCode { get; }
// Only a successful Get has Data. Failure must never look like an empty save.
public IReadOnlyDictionary<string, string> Data { get; }
// Null means the response did not supply timestamp metadata; never substitute read time.
public IReadOnlyDictionary<string, DateTime> UpdatedAtUtc
{
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)
Dictionary<string, string> data = null, bool mayHaveCommitted = false,
Dictionary<string, DateTime> updatedAtUtc = null)
{
Error = error;
StatusCode = statusCode;
Data = data == null ? null : new ReadOnlyDictionary<string, string>(data);
UpdatedAtUtc = updatedAtUtc == null ? null : new ReadOnlyDictionary<string, DateTime>(updatedAtUtc);
MayHaveCommitted = mayHaveCommitted;
}
}

View File

@@ -26,8 +26,6 @@ namespace game.PlayerDataStorage
return new PlayerKVRuntimeSelection(false, null);
if (backend != "kv")
throw new ArgumentException("Unsupported save backend.", nameof(backend));
if (agg)
throw new InvalidOperationException("KV login is not enabled for AGG channels.");
if (!string.Equals(api, FormalApiUrl, StringComparison.Ordinal))
throw new ArgumentException("KV API address does not match the reviewed endpoint.", nameof(api));
return new PlayerKVRuntimeSelection(true, new Uri(FormalApiUrl));

View File

@@ -1,126 +0,0 @@
#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 = KVMgr.ReadKV(a, "k"); Check(present.Success && present.Found == true && present.Value == "A");
Check(KVMgr.ReadKV(a, "missing").Found == false);
Check(KVMgr.ReadKV(a, "empty").Found == true && KVMgr.ReadKV(a, "empty").Value == "");
Check(KVMgr.ReadKV(a, null).Found == null);
var edit = KVMgr.WriteKV(a, "k", "remote"); Check(edit.Accepted && edit.Ticket != null && writes == 0);
var local = KVMgr.WriteKV(a, "k", "local", false); Check(local.Accepted && local.Ticket == null);
Check(KVMgr.ReadKV(a, "k").Value == "local");
var flush = await KVMgr.FlushKVAsync(a); Check(flush.Confirmed == 1 && remote["101"]["k"] == "remote");
Check((await KVMgr.WaitForKVTicketAsync(a, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed);
await bridge.CloseAsync(); epoch++;
var b = await Open("102"); var writesBefore = writes;
Check(KVMgr.ReadKV(a, "k").Error == PlayerKVKeyReadError.StaleOrClosing);
Check(!KVMgr.WriteKV(a, "k", "late").Accepted);
Check((await KVMgr.FlushKVAsync(a)).Blocked == 1);
Check((await KVMgr.WaitForKVTicketAsync(b, edit.Ticket)).Status == PlayerKVTicketStatus.Blocked);
Check(KVMgr.ReadKV(b, "k").Value == "B" && writes == writesBefore);
Check(PlayerKVGameLoadGuard.TryEnter(out guard));
Check(KVMgr.ReadKV(null, null).Error == PlayerKVKeyReadError.LoadGuardActive);
Check(KVMgr.WriteKV(null, null, null).StateResult.Error == PlayerKVChangeError.Blocked);
Check((await KVMgr.FlushKVAsync(b)).TransportError == PlayerKVError.Cancelled);
Check((await KVMgr.WaitForKVTicketAsync(b, null)).TransportError == PlayerKVError.Cancelled);
Check(guard.Violations == 4 && guard.Count(PlayerKVGuardPoint.ReadLocal) == 1 && guard.Count(PlayerKVGuardPoint.Update) == 1 && guard.Count(PlayerKVGuardPoint.Sync) == 2);
Check(auth == 2 && gets == 2 && writes == 1);
await Close();
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

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 860b04f8a2a64eb8acf77e96695f6c54

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3c49515cb90840a189772d23ad69fd2b

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Net;
@@ -76,7 +77,7 @@ namespace game.PlayerDataStorage
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)
TimeSpan? transportTimeout = null, bool prepareOriginalComparison = false)
{
if (session == null || expectedApiBaseUri == null || isCurrent == null || handlerFactory == null || epoch < 0 ||
!long.TryParse(expectedPlayerId, NumberStyles.None, CultureInfo.InvariantCulture, out var numericId) || numericId <= 0)
@@ -112,7 +113,7 @@ namespace game.PlayerDataStorage
using (var lease = owner.TryEnter())
{
if (lease == null) return await owner.FailCreate(PlayerKVSessionOpenError.Cancelled);
read = await owner.coordinator.InitializeAsync();
read = await owner.coordinator.InitializeAsync(prepareOriginalComparison);
}
if (!read.Success)
return await owner.FailCreate(read.Error == PlayerKVError.Unauthorized
@@ -166,9 +167,49 @@ namespace game.PlayerDataStorage
PlayerKVLocalReadResult ReadFailure(PlayerKVLocalReadError error) =>
new PlayerKVLocalReadResult(error, ownerId, PlayerId, Epoch);
public PlayerKVEditResult Update(string key, string value, bool remoteStore = true)
{
return Update(key, value, remoteStore, null);
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
using (var lease = TryEnter())
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore);
{
return lease == null ? new PlayerKVStateResult(PlayerKVChangeError.Blocked) : coordinator.CompareDatas(nowTicks);
}
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks)
{
using (var lease = TryEnter())
{
return lease == null ? ClosedEdit() : coordinator.Update(key, value, remoteStore, updateTicks);
}
}
public Task<PlayerKVFlushResult> SyncDataAsync(CancellationToken waitToken = default)
{
var lease = TryEnter();
if (lease == null)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status));
}
return WaitForFlushAsync(RunOrdinarySyncAsync(lease), waitToken);
}
private async Task<PlayerKVFlushResult> RunOrdinarySyncAsync(OperationLease lease)
{
using (lease)
{
try
{
return await coordinator.SyncDataAsync();
}
catch (Exception)
{
return new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Protocol, state.Status);
}
}
}
public PlayerKVEditResult Delete(string key)
@@ -177,6 +218,30 @@ namespace game.PlayerDataStorage
return lease == null ? ClosedEdit() : coordinator.Delete(key);
}
public async Task<bool> SyncDataImmidiate(string key, string value)
{
using (var lease = TryEnter())
{
if (lease == null)
{
return false;
}
return await coordinator.SyncDataImmidiate(key, value);
}
}
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
using (var lease = TryEnter())
{
if (lease == null)
{
return false;
}
return await coordinator.SyncDataImmidiate(values);
}
}
public Task<PlayerKVFlushResult> FlushAsync(CancellationToken waitToken = default)
{
var lease = TryEnter();
@@ -308,7 +373,10 @@ namespace game.PlayerDataStorage
bool IsCapturedOwnerCurrent()
{
if (lifetime.IsCancellationRequested || !isCurrent(Epoch)) return false;
if (lifetime.IsCancellationRequested || !isCurrent(Epoch) || state.AuthenticationInvalid)
{
return false;
}
return session.IsAuthenticated && ReferenceEquals(session.Cookies, cookies) &&
session.PlayerId == PlayerId && NormalizeApi(session.ApiBaseUri) == ApiBaseUri;
}

View File

@@ -42,11 +42,16 @@ namespace game.PlayerDataStorage
public int Superseded { get; }
public int Blocked { get; }
public int Unknown { get; }
public int Buffered
{
get;
}
public PlayerKVError TransportError { get; }
public PlayerKVSyncStatus CacheStatus { get; }
internal PlayerKVFlushResult(int confirmed, int superseded, int blocked, int unknown,
PlayerKVError transportError, PlayerKVSyncStatus cacheStatus)
PlayerKVError transportError, PlayerKVSyncStatus cacheStatus, int buffered = 0)
{
Buffered = buffered;
Confirmed = confirmed; Superseded = superseded; Blocked = blocked; Unknown = unknown;
TransportError = transportError; CacheStatus = cacheStatus;
}
@@ -74,7 +79,9 @@ namespace game.PlayerDataStorage
readonly long epoch;
readonly Func<bool> isOwnerCurrent;
readonly CancellationToken ownerToken;
bool disposed;
private bool disposed;
private bool ordinaryDraining;
private TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> ordinaryDrainCompletion;
internal PlayerKVSyncCoordinator(PlayerKVSyncState state, PlayerKVClient client,
System.Net.Http.HttpClient http, string ownerId, long epoch, Func<bool> isOwnerCurrent,
@@ -84,17 +91,21 @@ namespace game.PlayerDataStorage
this.epoch = epoch; this.isOwnerCurrent = isOwnerCurrent; this.ownerToken = ownerToken;
}
internal async Task<PlayerKVResult> InitializeAsync()
internal async Task<PlayerKVResult> InitializeAsync(bool prepareOriginalComparison = false)
{
await dispatch.WaitAsync(ownerToken);
try
{
if (!Current()) return CancelledResult();
if (state.HasPendingComparison)
{
return new PlayerKVResult(PlayerKVError.Protocol);
}
var result = await client.GetAsync(null, ownerToken);
if (!Current()) return CancelledResult();
if (result.Success)
{
var reconciled = state.Reconcile(result.Data);
var reconciled = state.Reconcile(result.Data, result.UpdatedAtUtc, prepareOriginalComparison);
return reconciled.Success ? result : new PlayerKVResult(PlayerKVError.Protocol);
}
state.RecordReadFailure(result.Error == PlayerKVError.Unauthorized);
@@ -103,127 +114,489 @@ namespace game.PlayerDataStorage
finally { dispatch.Release(); }
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore)
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
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));
if (!Current())
{
return new PlayerKVStateResult(PlayerKVChangeError.Blocked);
}
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.CompareDatas(nowTicks);
if (result.Success)
{
CompleteRemovedSources(previous);
}
return result;
}
}
internal PlayerKVEditResult Update(string key, string value, bool remoteStore, long? updateTicks = null)
{
lock (ticketGate)
{
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.Update(key, value, remoteStore, updateTicks);
if (!result.Success)
{
return new PlayerKVEditResult(result);
}
CompleteRemovedSources(previous);
if (!remoteStore || !Unsent(state.Inspect()).Any(item => item.Sequence == result.AttemptSequence))
{
return new PlayerKVEditResult(result);
}
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
return new PlayerKVEditResult(result, 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);
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
var result = state.Delete(key);
if (!result.Success)
{
return new PlayerKVEditResult(result);
}
CompleteRemovedSources(previous);
var ticket = new PlayerKVSyncTicket(ownerId, epoch, key, result.AttemptSequence);
tickets.Add(ticket.Sequence, new TicketRecord(ticket));
return new PlayerKVEditResult(result, ticket);
}
}
private static IEnumerable<PlayerKVChange> Unsent(PlayerKVDiagnosticSnapshot value)
{
return value.OrdinaryBuffers.SelectMany(bucket => bucket.Values)
.Concat(value.LegacyPending.Values).Concat(value.ExplicitDeletes.Values);
}
private void CompleteRemovedSources(IEnumerable<long> previous)
{
var remaining = new HashSet<long>(Unsent(state.Inspect()).Select(item => item.Sequence));
foreach (var sequence in previous)
{
if (!remaining.Contains(sequence) && tickets.TryGetValue(sequence, out var record) &&
!record.Dispatched && !record.Completion.Task.IsCompleted)
{
Complete(record, PlayerKVTicketStatus.Superseded, PlayerKVError.None);
}
}
}
internal Task<bool> SyncDataImmidiate(string key, string value)
{
if (!KVSnapshot.ValidKey(key) || value == null)
{
return Task.FromResult(false);
}
return SyncDataImmidiate(new Dictionary<string, string> { [key] = value });
}
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
if (values == null || values.Count == 0)
{
return false;
}
var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
IReadOnlyList<PlayerKVChange> operations;
lock (ticketGate)
{
var previous = Unsent(state.Inspect()).Select(item => item.Sequence).ToArray();
if (!Current() || !state.PrepareImmediate(data, out operations).Success)
{
return false;
}
CompleteRemovedSources(previous);
}
var acquired = false;
var dispatched = false;
try
{
await dispatch.WaitAsync(ownerToken);
acquired = true;
if (!Current() || state.Status != PlayerKVSyncStatus.Ready)
{
return false;
}
dispatched = true;
var result = await client.SetAsync(data, ownerToken);
if (!Current())
{
return false;
}
if (result.Success)
{
return state.CompleteImmediate(operations).Success;
}
if (result.Error == PlayerKVError.Unauthorized)
{
state.RecordReadFailure(true);
}
return false;
}
catch (Exception)
{
return false;
}
finally
{
if (!dispatched)
{
state.CancelUndispatchedImmediate(operations);
}
if (acquired)
{
dispatch.Release();
}
}
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();
var snapshot = state.Inspect();
var watermark = snapshot.NextSequence <= 1 ? 0 : snapshot.NextSequence - 1;
var target = new HashSet<long>(Outstanding(snapshot).Where(item => item.Sequence <= watermark).Select(item => item.Sequence));
foreach (var record in tickets.Values)
{
if (record.Ticket.Sequence <= watermark && !record.ClaimedByFlush)
{
record.ClaimedByFlush = true;
target.Add(record.Ticket.Sequence);
}
}
targetSequences = target.ToArray();
return watermark;
}
return watermark;
}
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences)
private static IEnumerable<PlayerKVChange> Outstanding(PlayerKVDiagnosticSnapshot snapshot)
{
PlayerKVError finalError = PlayerKVError.None;
var targetSet = new HashSet<long>(targetSequences ?? Array.Empty<long>());
var recoveredConfirmed = 0;
var recoveredBlocked = 0;
var recoveredUnknown = 0;
return Unsent(snapshot).Concat(snapshot.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
.Concat(snapshot.ActiveBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.ActiveBatch.Items)
.Concat(snapshot.UnknownBatch == null ? Enumerable.Empty<PlayerKVChange>() : snapshot.UnknownBatch.Items);
}
// This explicit channel only sends imported legacy intents and explicit deletes, never ordinary buckets.
internal async Task<PlayerKVFlushResult> FlushThroughAsync(long watermark, long[] targetSequences,
bool legacyOnly = false, int maximumBatches = int.MaxValue)
{
var error = PlayerKVError.None;
var observed = new Dictionary<long, PlayerKVTicketStatus>();
var acquired = false;
try
{
await dispatch.WaitAsync(ownerToken);
acquired = true;
while (Current())
var sent = 0;
while (Current() && sent < maximumBatches)
{
MarkSuperseded(watermark);
var begin = state.BeginBatch(watermark, out var batch);
PlayerKVStateResult begin;
PlayerKVBatch batch;
lock (ticketGate)
{
begin = state.BeginBatch(watermark, legacyOnly, out batch);
if (begin.Success && batch != null)
{
MarkDispatched(batch);
}
}
if (!begin.Success)
{
recoveredBlocked += state.Inspect().Pending.Values.Count(item =>
item.Sequence <= watermark && !targetSet.Contains(item.Sequence));
MarkEligibleBlocked(watermark, PlayerKVError.None);
MarkExplicitBlocked(watermark, legacyOnly, PlayerKVError.None, observed);
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 (batch == null)
{
break;
}
sent++;
var result = await SendAsync(batch);
if (!Current() && result.Success)
{
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
}
if (result.Success)
{
var confirmed = state.ConfirmBatch(batch.Id);
var outcome = confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown;
RecordBatch(batch, outcome, PlayerKVError.None, observed);
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 :
error = result.Error;
state.FailBatch(batch.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized);
var failedStatus = 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);
RecordBatch(batch, failedStatus, result.Error, observed);
break;
}
if (!Current())
{
if (finalError == PlayerKVError.None) finalError = PlayerKVError.Cancelled;
MarkEligibleOnClose(watermark);
error = PlayerKVError.Cancelled;
CloseTickets();
}
}
catch (OperationCanceledException)
{
finalError = PlayerKVError.Cancelled;
MarkEligibleOnClose(watermark);
error = PlayerKVError.Cancelled;
CloseTickets();
}
catch (Exception)
{
finalError = PlayerKVError.Protocol;
MarkEligibleBlocked(watermark, finalError);
error = PlayerKVError.Protocol;
MarkExplicitBlocked(watermark, legacyOnly, error, observed);
}
finally
{
if (acquired)
{
dispatch.Release();
}
}
return Summary(targetSequences ?? Array.Empty<long>(), error, observed);
}
internal Task<PlayerKVFlushResult> SyncDataAsync()
{
TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> completion;
bool start;
long[] targets;
lock (ticketGate)
{
var snapshot = state.Inspect();
if (!Current())
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled, state.Status));
}
if (snapshot.LegacyPending.Count != 0)
{
var watermark = snapshot.NextSequence - 1;
return FlushThroughAsync(watermark, snapshot.LegacyPending.Values.Select(item => item.Sequence).ToArray(), true, 1);
}
var captured = state.CaptureOrdinaryBatch(out var batch);
if (!captured.Success)
{
foreach (var item in snapshot.OrdinaryBuffers[0].Values)
{
if (tickets.TryGetValue(item.Sequence, out var record))
{
Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None);
}
}
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.None, state.Status));
}
snapshot = state.Inspect();
if (batch == null && ordinaryDraining)
{
// The existing caller owns the queued work; an empty tick has no new result to observe.
return Task.FromResult(Summary(Array.Empty<long>(), PlayerKVError.None));
}
targets = batch != null ? batch.Items.Select(item => item.Sequence).ToArray() :
snapshot.PreparedOrdinaryBatches.SelectMany(item => item.Items).Select(item => item.Sequence).ToArray();
if (snapshot.PreparedOrdinaryBatches.Count == 0)
{
return Task.FromResult(Summary(targets, PlayerKVError.None));
}
start = !ordinaryDraining;
if (start)
{
ordinaryDraining = true;
ordinaryDrainCompletion = new TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)>(TaskCreationOptions.RunContinuationsAsynchronously);
}
completion = ordinaryDrainCompletion;
}
if (start)
{
// The returned task waits for this actual worker, so the owner's lease covers its finally.
_ = DrainOrdinaryAsync(completion);
}
return ObserveOrdinaryAsync(completion.Task, targets);
}
private async Task<PlayerKVFlushResult> ObserveOrdinaryAsync(Task<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> worker, long[] targets)
{
var completed = await worker;
var error = targets.Where(completed.Results.ContainsKey).Select(sequence => completed.Results[sequence].TransportError)
.FirstOrDefault(value => value != PlayerKVError.None);
if (error == PlayerKVError.None && targets.Any(sequence => !completed.Results.ContainsKey(sequence)))
{
error = completed.Error;
}
return Summary(targets, error, completed.Results.ToDictionary(pair => pair.Key, pair => pair.Value.Status));
}
private async Task DrainOrdinaryAsync(TaskCompletionSource<(PlayerKVError Error, Dictionary<long, PlayerKVTicketResult> Results)> completion)
{
var error = PlayerKVError.None;
var observed = new Dictionary<long, PlayerKVTicketResult>();
void RecordOrdinaryBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError transportError)
{
CompleteBatch(batch, status, transportError);
foreach (var item in batch.Items)
{
observed[item.Sequence] = new PlayerKVTicketResult(status, transportError, state.Status);
}
}
PlayerKVBatch active = null;
var acquired = false;
var dispatched = false;
try
{
while (Current())
{
await dispatch.WaitAsync(ownerToken);
acquired = true;
PlayerKVStateResult begin;
lock (ticketGate)
{
begin = state.BeginOrdinaryBatch(out active);
if (!begin.Success || active == null)
{
// Enqueue and the end-of-drain decision share this lock; no Task.IsCompleted race.
ordinaryDraining = false;
if (!begin.Success)
{
foreach (var item in state.Inspect().PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
{
if (tickets.TryGetValue(item.Sequence, out var record))
{
Complete(record, PlayerKVTicketStatus.Blocked, PlayerKVError.None);
}
}
}
break;
}
MarkDispatched(active);
}
dispatched = true;
var result = await SendAsync(active);
if (!Current() && result.Success)
{
result = new PlayerKVResult(PlayerKVError.Cancelled, mayHaveCommitted: true);
}
if (result.Success)
{
var confirmed = state.ConfirmBatch(active.Id);
RecordOrdinaryBatch(active, confirmed.Success ? PlayerKVTicketStatus.Confirmed : PlayerKVTicketStatus.Unknown, PlayerKVError.None);
if (!confirmed.Success)
{
break;
}
}
else
{
error = result.Error;
var failed = state.FailBatch(active.Id, result.MayHaveCommitted, result.Error == PlayerKVError.Unauthorized);
RecordOrdinaryBatch(active, result.MayHaveCommitted ? PlayerKVTicketStatus.Unknown :
ownerToken.IsCancellationRequested ? PlayerKVTicketStatus.Cancelled : PlayerKVTicketStatus.Blocked, result.Error);
if (!failed.Success || state.Status != PlayerKVSyncStatus.Ready)
{
break;
}
}
active = null;
dispatched = false;
dispatch.Release();
acquired = false;
}
}
catch (OperationCanceledException)
{
error = PlayerKVError.Cancelled;
if (active != null)
{
state.FailBatch(active.Id, dispatched);
RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Cancelled, error);
}
}
catch (Exception)
{
error = PlayerKVError.Protocol;
if (active != null)
{
state.FailBatch(active.Id, dispatched);
RecordOrdinaryBatch(active, dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked, error);
}
}
finally
{
if (acquired)
{
dispatch.Release();
}
lock (ticketGate)
{
if (ReferenceEquals(ordinaryDrainCompletion, completion))
{
ordinaryDraining = false;
ordinaryDrainCompletion = null;
}
try
{
if (!Current())
{
CloseTickets();
}
}
catch (Exception)
{
error = PlayerKVError.Protocol;
}
}
completion.TrySetResult((error, observed));
}
}
private Task<PlayerKVResult> SendAsync(PlayerKVBatch batch)
{
if (batch.IsDelete)
{
return client.DeleteAsync(batch.Items.Select(item => item.Key), ownerToken);
}
return client.SetAsync(batch.Items.ToDictionary(item => item.Key, item => item.Target.Value, StringComparer.Ordinal), ownerToken);
}
private void RecordBatch(PlayerKVBatch batch, PlayerKVTicketStatus status, PlayerKVError error,
Dictionary<long, PlayerKVTicketStatus> observed)
{
CompleteBatch(batch, status, error);
foreach (var item in batch.Items)
{
observed[item.Sequence] = status;
}
}
private void MarkExplicitBlocked(long watermark, bool legacyOnly, PlayerKVError error,
Dictionary<long, PlayerKVTicketStatus> observed)
{
lock (ticketGate)
{
var snapshot = state.Inspect();
var source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values);
foreach (var item in source.Where(item => item.Sequence <= watermark))
{
var outcome = PlayerKVTicketStatus.Blocked;
if (tickets.TryGetValue(item.Sequence, out var record))
{
outcome = record.Dispatched ? PlayerKVTicketStatus.Unknown : PlayerKVTicketStatus.Blocked;
Complete(record, outcome, error);
}
observed[item.Sequence] = outcome;
}
}
finally { if (acquired) dispatch.Release(); }
return Summary(targetSequences ?? Array.Empty<long>(), finalError,
recoveredConfirmed, recoveredBlocked, recoveredUnknown);
}
internal async Task<PlayerKVTicketResult> WaitForTicketAsync(PlayerKVSyncTicket ticket, CancellationToken token)
@@ -269,60 +642,55 @@ namespace game.PlayerDataStorage
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)
private PlayerKVFlushResult Summary(IEnumerable<long> targetSequences, PlayerKVError error,
Dictionary<long, PlayerKVTicketStatus> observed = null)
{
var counts = new Dictionary<PlayerKVTicketStatus, int>();
lock (ticketGate)
foreach (var sequence in targetSequences)
{
var counts = new Dictionary<PlayerKVTicketStatus, int>();
var snapshot = state.Inspect();
var unknown = new HashSet<long>(snapshot.OrdinaryUnknownBatches.SelectMany(batch => batch.Items).Select(item => item.Sequence));
if (snapshot.UnknownBatch != null)
{
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;
unknown.UnionWith(snapshot.UnknownBatch.Items.Select(item => item.Sequence));
}
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);
var outstanding = new HashSet<long>(Outstanding(snapshot).Select(item => item.Sequence));
foreach (var sequence in targetSequences.Distinct())
{
PlayerKVTicketStatus outcome;
if (tickets.TryGetValue(sequence, out var record) && record.Completion.Task.IsCompleted)
{
outcome = record.Completion.Task.GetAwaiter().GetResult().Status;
}
else if (observed != null && observed.TryGetValue(sequence, out var actual))
{
outcome = actual;
}
else if (unknown.Contains(sequence))
{
outcome = PlayerKVTicketStatus.Unknown;
}
else if (outstanding.Contains(sequence) || tickets.ContainsKey(sequence))
{
outcome = PlayerKVTicketStatus.Pending;
}
else
{
continue;
}
counts[outcome] = counts.TryGetValue(outcome, out var count) ? count + 1 : 1;
}
int Count(PlayerKVTicketStatus status)
{
return counts.TryGetValue(status, out var count) ? count : 0;
}
return new PlayerKVFlushResult(Count(PlayerKVTicketStatus.Confirmed), Count(PlayerKVTicketStatus.Superseded),
Count(PlayerKVTicketStatus.Blocked) + Count(PlayerKVTicketStatus.Cancelled), Count(PlayerKVTicketStatus.Unknown),
error, state.Status, Count(PlayerKVTicketStatus.Pending));
}
}
bool Current() => !ownerToken.IsCancellationRequested && isOwnerCurrent();

View File

@@ -65,8 +65,16 @@ namespace game.PlayerDataStorage
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 bool IsOrdinary
{
get;
}
internal PlayerKVBatch(string id, IEnumerable<PlayerKVChange> items, bool ordinary = false)
{
Id = id;
Items = Array.AsReadOnly(items.ToArray());
IsOrdinary = ordinary;
}
}
public sealed class PlayerKVDiagnosticSnapshot
@@ -81,6 +89,34 @@ namespace game.PlayerDataStorage
public IReadOnlyDictionary<string, PlayerKVValue> ConflictObservations { get; }
public PlayerKVBatch ActiveBatch { get; }
public PlayerKVBatch UnknownBatch { get; }
public IReadOnlyDictionary<string, string> LocalData
{
get;
}
public IReadOnlyDictionary<string, long> LocalUpdateTicks
{
get;
}
public IReadOnlyList<IReadOnlyDictionary<string, PlayerKVChange>> OrdinaryBuffers
{
get;
}
public IReadOnlyList<PlayerKVBatch> PreparedOrdinaryBatches
{
get;
}
public IReadOnlyList<PlayerKVBatch> OrdinaryUnknownBatches
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> LegacyPending
{
get;
}
public IReadOnlyDictionary<string, PlayerKVChange> ExplicitDeletes
{
get;
}
internal PlayerKVDiagnosticSnapshot(KVSnapshot source)
{
Revision = source.Revision; NextSequence = source.NextSequence; HasBaseline = source.HasBaseline;
@@ -88,6 +124,13 @@ namespace game.PlayerDataStorage
Baseline = ReadOnly(source.Baseline); LocalOnly = ReadOnly(source.LocalOnly);
Pending = ReadOnly(source.Pending); ConflictObservations = ReadOnly(source.Conflicts);
ActiveBatch = source.Active; UnknownBatch = source.Unknown;
LocalData = ReadOnly(source.LocalData);
LocalUpdateTicks = ReadOnly(source.LocalUpdateTicks);
OrdinaryBuffers = Array.AsReadOnly(source.OrdinaryBuffers.Select(bucket => ReadOnly(bucket)).ToArray());
PreparedOrdinaryBatches = Array.AsReadOnly(source.PreparedOrdinaryBatches.ToArray());
OrdinaryUnknownBatches = Array.AsReadOnly(source.OrdinaryUnknownBatches.ToArray());
LegacyPending = ReadOnly(source.LegacyPending);
ExplicitDeletes = ReadOnly(source.ExplicitDeletes);
}
static IReadOnlyDictionary<string, T> ReadOnly<T>(Dictionary<string, T> source) =>
new ReadOnlyDictionary<string, T>(new Dictionary<string, T>(source, StringComparer.Ordinal));
@@ -118,38 +161,111 @@ namespace game.PlayerDataStorage
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, string> LocalData = new Dictionary<string, string>(StringComparer.Ordinal);
internal Dictionary<string, long> LocalUpdateTicks = new Dictionary<string, long>(StringComparer.Ordinal);
// A local origin has no second copy of its value. Ordinary origins can outlive a sent batch.
internal Dictionary<string, (bool LocalOnly, long Sequence)> LocalOrigins =
new Dictionary<string, (bool LocalOnly, long Sequence)>(StringComparer.Ordinal);
internal HashSet<string> ImmediatePreservedKeys = new HashSet<string>(StringComparer.Ordinal);
// Only v1/v2 conversion can populate this lower layer; the first successful Get removes it.
internal Dictionary<string, string> LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(StringComparer.Ordinal);
internal List<Dictionary<string, PlayerKVChange>> OrdinaryBuffers = Enumerable.Range(0, 5)
.Select(_ => new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal)).ToList();
internal List<PlayerKVBatch> PreparedOrdinaryBatches = new List<PlayerKVBatch>();
internal List<PlayerKVBatch> OrdinaryUnknownBatches = new List<PlayerKVBatch>();
internal Dictionary<string, PlayerKVChange> LegacyPending = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVChange> ExplicitDeletes = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
internal Dictionary<string, PlayerKVValue> Conflicts = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
internal Dictionary<long, PlayerKVChange> RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>();
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
};
// Compatibility diagnostics only. A key projection loses cross-bucket sequence history.
internal Dictionary<string, PlayerKVChange> Pending => Unsent().GroupBy(item => item.Key, StringComparer.Ordinal)
.ToDictionary(group => group.Key, group => group.OrderBy(item => item.Sequence).Last(), StringComparer.Ordinal);
internal Dictionary<string, string> LocalOnly => LocalOrigins.Where(pair => pair.Value.LocalOnly && LocalData.ContainsKey(pair.Key))
.ToDictionary(pair => pair.Key, pair => LocalData[pair.Key], StringComparer.Ordinal);
internal IEnumerable<PlayerKVChange> Unsent()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values).Concat(LegacyPending.Values).Concat(ExplicitDeletes.Values);
}
internal IEnumerable<PlayerKVChange> OrdinaryOutstanding()
{
return OrdinaryBuffers.SelectMany(bucket => bucket.Values)
.Concat(PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(Active != null && Active.IsOrdinary ? Active.Items : Enumerable.Empty<PlayerKVChange>());
}
internal KVSnapshot Clone()
{
return new KVSnapshot
{
Revision = Revision, NextSequence = NextSequence, HasBaseline = HasBaseline, AuthenticationInvalid = AuthenticationInvalid,
Baseline = new Dictionary<string, string>(Baseline, StringComparer.Ordinal),
LocalData = new Dictionary<string, string>(LocalData, StringComparer.Ordinal),
LocalUpdateTicks = new Dictionary<string, long>(LocalUpdateTicks, StringComparer.Ordinal),
LocalOrigins = new Dictionary<string, (bool LocalOnly, long Sequence)>(LocalOrigins, StringComparer.Ordinal),
ImmediatePreservedKeys = new HashSet<string>(ImmediatePreservedKeys, StringComparer.Ordinal),
LegacyLocalOnlyUnderImmediate = new Dictionary<string, string>(LegacyLocalOnlyUnderImmediate, StringComparer.Ordinal),
OrdinaryBuffers = OrdinaryBuffers.Select(bucket => new Dictionary<string, PlayerKVChange>(bucket, StringComparer.Ordinal)).ToList(),
PreparedOrdinaryBatches = new List<PlayerKVBatch>(PreparedOrdinaryBatches),
OrdinaryUnknownBatches = new List<PlayerKVBatch>(OrdinaryUnknownBatches),
LegacyPending = new Dictionary<string, PlayerKVChange>(LegacyPending, StringComparer.Ordinal),
ExplicitDeletes = new Dictionary<string, PlayerKVChange>(ExplicitDeletes, StringComparer.Ordinal),
Conflicts = new Dictionary<string, PlayerKVValue>(Conflicts, StringComparer.Ordinal),
Active = Active, Unknown = Unknown,
RemoteOnlyWrites = new Dictionary<long, PlayerKVChange>(RemoteOnlyWrites)
};
}
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,
foreach (var pair in Conflicts)
{
conflicts.Add(pair.Key, ValueJson(pair.Value));
}
var origins = new JObject();
foreach (var pair in LocalOrigins)
{
origins.Add(pair.Key, new JObject { ["localOnly"] = pair.Value.LocalOnly, ["sequence"] = pair.Value.Sequence });
}
return new JObject
{
["formatVersion"] = 3, ["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)
["localData"] = JObject.FromObject(LocalData), ["localUpdateTicks"] = JObject.FromObject(LocalUpdateTicks),
["localOrigins"] = origins, ["immediatePreservedKeys"] = new JArray(ImmediatePreservedKeys),
["legacyLocalOnlyUnderImmediate"] = JObject.FromObject(LegacyLocalOnlyUnderImmediate),
["ordinaryBuffers"] = new JArray(OrdinaryBuffers.Select(bucket => (JToken)ChangesJson(bucket.Values))),
["preparedOrdinaryBatches"] = new JArray(PreparedOrdinaryBatches.Select(BatchJson)),
["ordinaryUnknownBatches"] = new JArray(OrdinaryUnknownBatches.Select(BatchJson)),
["legacyPending"] = ChangesJson(LegacyPending.Values), ["explicitDeletes"] = ChangesJson(ExplicitDeletes.Values),
["conflictObservations"] = conflicts, ["activeBatch"] = BatchJson(Active), ["unknownBatch"] = BatchJson(Unknown),
["remoteOnlyWrites"] = ChangesJson(RemoteOnlyWrites.Values)
}.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))
};
private static JArray ChangesJson(IEnumerable<PlayerKVChange> values)
{
return new JArray(values.OrderBy(item => item.Sequence).Select(ChangeJson));
}
private static JObject ValueJson(PlayerKVValue value)
{
return new JObject { ["exists"] = value.Exists, ["value"] = value.Value };
}
private static JObject ChangeJson(PlayerKVChange item)
{
return new JObject { ["key"] = item.Key, ["sequence"] = item.Sequence,
["base"] = ValueJson(item.BaseValue), ["target"] = ValueJson(item.Target) };
}
private static JToken BatchJson(PlayerKVBatch batch)
{
if (batch == null)
{
return JValue.CreateNull();
}
return new JObject { ["id"] = batch.Id, ["ordinary"] = batch.IsOrdinary, ["items"] = ChangesJson(batch.Items) };
}
internal static KVSnapshot Decode(string text, KVIdentity identity)
{
@@ -157,48 +273,268 @@ namespace game.PlayerDataStorage
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 (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 {
var version = Integer(root, "formatVersion");
if (version < 1 || version > 3 || 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")
Baseline = Strings(Field(root, "baseline", JTokenType.Object)),
Active = Batch(root, "activeBatch", version == 3), Unknown = Batch(root, "unknownBatch", version == 3)
};
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();
if (!ValidKey(property.Name))
{
throw Invalid();
}
result.Conflicts.Add(property.Name, Value(property.Value));
}
if (version >= 2)
{
foreach (var item in Field(root, "remoteOnlyWrites", JTokenType.Array))
{
var change = Change(item);
if (!change.Target.Exists || result.RemoteOnlyWrites.ContainsKey(change.Sequence))
{
throw Invalid();
}
result.RemoteOnlyWrites.Add(change.Sequence, change);
}
}
if (version < 3)
{
DecodeLegacy(root, version, result);
}
else
{
result.LocalData = Strings(Field(root, "localData", JTokenType.Object));
result.LegacyLocalOnlyUnderImmediate = Strings(Field(root, "legacyLocalOnlyUnderImmediate", JTokenType.Object));
foreach (var property in ((JObject)Field(root, "localUpdateTicks", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name) || property.Value.Type != JTokenType.Integer)
{
throw Invalid();
}
result.LocalUpdateTicks.Add(property.Name, (long)property.Value);
}
foreach (var property in ((JObject)Field(root, "localOrigins", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name))
{
throw Invalid();
}
result.LocalOrigins.Add(property.Name, (Boolean(property.Value, "localOnly"), Integer(property.Value, "sequence")));
}
foreach (var key in Field(root, "immediatePreservedKeys", JTokenType.Array))
{
if (key.Type != JTokenType.String || !ValidKey((string)key) || !result.ImmediatePreservedKeys.Add((string)key))
{
throw Invalid();
}
}
result.OrdinaryBuffers = Field(root, "ordinaryBuffers", JTokenType.Array).Select(Changes).ToList();
result.LegacyPending = Changes(Field(root, "legacyPending", JTokenType.Array));
result.ExplicitDeletes = Changes(Field(root, "explicitDeletes", JTokenType.Array));
result.PreparedOrdinaryBatches = ReadBatches(root, "preparedOrdinaryBatches");
result.OrdinaryUnknownBatches = ReadBatches(root, "ordinaryUnknownBatches");
}
result.Validate();
return result;
}
private static Dictionary<string, PlayerKVChange> Changes(JToken array)
{
if (array.Type != JTokenType.Array)
{
throw Invalid();
}
var values = new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal);
foreach (var token in array)
{
var item = Change(token);
if (values.ContainsKey(item.Key))
{
throw Invalid();
}
values.Add(item.Key, item);
}
return values;
}
private static List<PlayerKVBatch> ReadBatches(JToken root, string name)
{
var result = new List<PlayerKVBatch>();
foreach (var item in Field(root, name, JTokenType.Array))
{
var wrapper = new JObject { ["batch"] = item.DeepClone() };
var batch = Batch(wrapper, "batch", true);
if (batch == null || !batch.IsOrdinary || batch.IsDelete)
{
throw Invalid();
}
result.Add(batch);
}
return result;
}
private static void DecodeLegacy(JObject root, long version, KVSnapshot result)
{
var pending = Changes(Field(root, "pending", JTokenType.Array));
var localOnly = Strings(Field(root, "localOnly", JTokenType.Object));
var overrides = new Dictionary<string, PlayerKVValue>(StringComparer.Ordinal);
if (version == 2)
{
foreach (var property in ((JObject)Field(root, "remoteLocalOverrides", JTokenType.Object)).Properties())
{
if (!ValidKey(property.Name))
{
throw Invalid();
}
overrides.Add(property.Name, Value(property.Value));
}
}
// Validate the old shape before converting; new format rules must not accept a corrupt old file.
if (result.Revision < 1 || result.NextSequence < 1 || (result.Active != null && result.Unknown != null) ||
(!result.HasBaseline && (result.Baseline.Count != 0 || localOnly.Count != 0 || pending.Count != 0 ||
result.Active != null || result.Unknown != null || overrides.Count != 0 || result.RemoteOnlyWrites.Count != 0)))
{
throw Invalid();
}
var sequences = new HashSet<long>();
foreach (var item in pending.Values.Concat(result.RemoteOnlyWrites.Values))
{
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || !sequences.Add(item.Sequence))
{
throw Invalid();
}
}
if (result.Conflicts.Keys.Any(key => !pending.ContainsKey(key)))
{
throw Invalid();
}
var oldBatch = result.Active ?? result.Unknown;
if (oldBatch != null)
{
foreach (var item in oldBatch.Items)
{
var hasPending = pending.TryGetValue(item.Key, out var latest);
var hasSource = result.RemoteOnlyWrites.Values.Any(source => source.Key == item.Key && source.Sequence > item.Sequence);
var same = hasPending && latest.Sequence == item.Sequence && latest.BaseValue.Equals(item.BaseValue) && latest.Target.Equals(item.Target);
if (item.Sequence < 1 || item.Sequence >= result.NextSequence || (sequences.Contains(item.Sequence) && !same) ||
(!hasPending && !hasSource) || (hasPending && (latest.Sequence < item.Sequence || !latest.BaseValue.Equals(item.BaseValue) ||
(latest.Sequence == item.Sequence && !same))))
{
throw Invalid();
}
sequences.Add(item.Sequence);
}
}
result.LegacyPending = pending;
result.LocalData = new Dictionary<string, string>(result.Baseline, StringComparer.Ordinal);
foreach (var item in pending.Values)
{
ApplyValue(result.LocalData, item.Key, item.Target);
result.LocalOrigins[item.Key] = (false, item.Sequence);
}
foreach (var pair in localOnly)
{
result.LocalData[pair.Key] = pair.Value;
// Old local-only writes had no stored sequence; zero is permitted only for this converted origin.
result.LocalOrigins[pair.Key] = (true, 0);
}
foreach (var pair in overrides)
{
ApplyValue(result.LocalData, pair.Key, pair.Value);
result.ImmediatePreservedKeys.Add(pair.Key);
if (localOnly.TryGetValue(pair.Key, out var lower))
{
result.LegacyLocalOnlyUnderImmediate[pair.Key] = lower;
}
}
}
private static void ApplyValue(Dictionary<string, string> values, string key, PlayerKVValue value)
{
if (value.Exists)
{
values[key] = value.Value;
}
else
{
values.Remove(key);
}
}
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 (Revision < 1 || NextSequence < 1 || OrdinaryBuffers.Count != 5 ||
(Active != null && Unknown != null) || (Unknown != null && Unknown.IsOrdinary) ||
OrdinaryBuffers.Any(bucket => bucket.Count > 10 || bucket.Values.Any(item => !item.Target.Exists)) ||
ExplicitDeletes.Values.Any(item => item.Target.Exists) ||
(!HasBaseline && (Baseline.Count != 0 || LocalData.Count != 0 || Unsent().Any() || Active != null || Unknown != null ||
PreparedOrdinaryBatches.Count != 0 || OrdinaryUnknownBatches.Count != 0 || RemoteOnlyWrites.Count != 0 ||
LocalOrigins.Count != 0 || LocalUpdateTicks.Count != 0 || ImmediatePreservedKeys.Count != 0 || LegacyLocalOnlyUnderImmediate.Count != 0)))
{
if (item.Sequence < 1 || item.Sequence >= NextSequence || sequences.ContainsKey(item.Sequence)) throw Invalid();
sequences.Add(item.Sequence, item.Key);
throw Invalid();
}
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)
var sequences = new Dictionary<long, PlayerKVChange>();
void Add(PlayerKVChange item, bool sourceReference = false)
{
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();
if (!ValidKey(item.Key) || item.Sequence < 1 || item.Sequence >= NextSequence)
{
throw Invalid();
}
if (sequences.TryGetValue(item.Sequence, out var prior))
{
if (!sourceReference || prior.Key != item.Key || !prior.BaseValue.Equals(item.BaseValue) || !prior.Target.Equals(item.Target))
{
throw Invalid();
}
}
else
{
sequences.Add(item.Sequence, item);
}
}
foreach (var item in Unsent().Concat(RemoteOnlyWrites.Values))
{
Add(item);
}
var ids = new HashSet<string>(StringComparer.Ordinal);
foreach (var batch in PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Concat(new[] { Active, Unknown }).Where(batch => batch != null))
{
if (!Guid.TryParseExact(batch.Id, "N", out _) || !ids.Add(batch.Id) || batch.Items.Count < 1 || batch.Items.Count > 10 ||
batch.Items.Select(item => item.Key).Distinct(StringComparer.Ordinal).Count() != batch.Items.Count ||
batch.Items.Any(item => item.Target.Exists != batch.Items[0].Target.Exists) || (batch.IsOrdinary && batch.IsDelete) ||
!batch.Items.Select(item => item.Sequence).SequenceEqual(batch.Items.Select(item => item.Sequence).OrderBy(value => value)))
{
throw Invalid();
}
foreach (var item in batch.Items)
{
var reference = !batch.IsOrdinary && (LegacyPending.Values.Concat(ExplicitDeletes.Values)
.Any(source => source.Sequence == item.Sequence));
Add(item, reference);
}
}
if (RemoteOnlyWrites.Any(pair => pair.Key != pair.Value.Sequence || !pair.Value.Target.Exists) ||
PreparedOrdinaryBatches.Concat(OrdinaryUnknownBatches).Any(batch => !batch.IsOrdinary) ||
LocalOrigins.Any(pair => !ValidKey(pair.Key) || pair.Value.Sequence < 0 || pair.Value.Sequence >= NextSequence ||
(!pair.Value.LocalOnly && pair.Value.Sequence == 0)) ||
LocalUpdateTicks.Any(pair => !ValidKey(pair.Key) || pair.Value < 0 || pair.Value > DateTime.MaxValue.Ticks) ||
ImmediatePreservedKeys.Any(key => !ValidKey(key)) ||
LegacyLocalOnlyUnderImmediate.Any(pair => !ImmediatePreservedKeys.Contains(pair.Key) ||
!LocalOrigins.TryGetValue(pair.Key, out var origin) || !origin.LocalOnly) ||
Conflicts.Keys.Any(key => !Unsent().Any(item => item.Key == key) && !PreparedOrdinaryBatches.Any(batch => batch.Items.Any(item => item.Key == key)) &&
Unknown == null && OrdinaryUnknownBatches.Count == 0))
{
throw Invalid();
}
}
static Dictionary<string, string> Strings(JToken value)
@@ -223,7 +559,7 @@ namespace game.PlayerDataStorage
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)
static PlayerKVBatch Batch(JToken parent, string name, bool withKind = false)
{
var node = parent[name];
if (node == null) throw Invalid();
@@ -234,7 +570,7 @@ namespace game.PlayerDataStorage
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);
return new PlayerKVBatch(id, items, withKind && Boolean(node, "ordinary"));
}
static JToken Field(JToken parent, string name, JTokenType type)
{

View File

@@ -13,10 +13,38 @@ namespace game.PlayerDataStorage
/// </summary>
public sealed class PlayerKVSyncState : IDisposable
{
readonly object gate = new object();
readonly PlayerKVCacheStore store;
KVSnapshot snapshot;
PlayerKVSyncStatus status;
private readonly object gate = new object();
private readonly PlayerKVCacheStore store;
private KVSnapshot snapshot;
private PlayerKVSyncStatus status;
private readonly HashSet<long> recoveredImmediate;
private bool recoverImmediateView = true;
private bool recoveredOrdinaryUnknown;
private KVSnapshot comparisonLocal;
private Dictionary<string, string> comparisonRemote;
private Dictionary<string, DateTime> comparisonTimes;
private Dictionary<string, string> comparisonView;
private HashSet<string> comparisonImmediateKeys;
internal bool HasPendingComparison
{
get
{
lock (gate)
{
return comparisonLocal != null;
}
}
}
internal bool AuthenticationInvalid
{
get
{
lock (gate)
{
return snapshot.AuthenticationInvalid;
}
}
}
public PlayerKVSyncStatus Status { get { lock (gate) return status; } }
public string ApiBaseUri { get; }
public string PlayerId { get; }
@@ -24,12 +52,26 @@ namespace game.PlayerDataStorage
PlayerKVSyncState(KVIdentity identity, PlayerKVCacheStore store, KVSnapshot snapshot)
{
ApiBaseUri = identity.Api; PlayerId = identity.PlayerId; this.store = store; this.snapshot = snapshot;
recoveredImmediate = new HashSet<long>(snapshot.RemoteOnlyWrites.Keys);
// 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;
if (snapshot.Active != null)
{
if (snapshot.Active.IsOrdinary)
{
snapshot.OrdinaryUnknownBatches.Add(snapshot.Active);
}
else
{
snapshot.Unknown = snapshot.Active;
}
snapshot.Active = null;
}
recoveredOrdinaryUnknown = snapshot.OrdinaryUnknownBatches.Count != 0;
status = snapshot.Unknown != null || recoveredOrdinaryUnknown
? PlayerKVSyncStatus.UnknownOutcome : PlayerKVSyncStatus.NotLoaded;
}
public static PlayerKVOpenResult Open(string root, Uri api, string playerId) => Open(root, api, playerId, null);
@@ -50,39 +92,307 @@ namespace game.PlayerDataStorage
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);
return new ReadOnlyDictionary<string, string>(ReadView());
}
}
// The caller must supply a successful complete Get. Failed reads must use RecordReadFailure.
public PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote)
{
return Reconcile(remote, null, false);
}
internal PlayerKVStateResult Reconcile(IReadOnlyDictionary<string, string> remote,
IReadOnlyDictionary<string, DateTime> updatedAtUtc, bool prepareOriginalComparison)
{
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();
if (Terminal || snapshot.Active != null || snapshot.AuthenticationInvalid || comparisonLocal != null)
{
return Blocked();
}
if (remote == null || remote.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null))
{
return Invalid();
}
if (prepareOriginalComparison && (!recoverImmediateView || updatedAtUtc == null ||
updatedAtUtc.Count != remote.Count || remote.Keys.Any(key => !updatedAtUtc.ContainsKey(key)) ||
updatedAtUtc.Values.Any(time => time.Kind != DateTimeKind.Utc)))
{
return Invalid();
}
var original = snapshot;
var copy = snapshot.Clone();
copy.Conflicts.Clear();
if (copy.Unknown != null)
if (copy.Unknown != null || (recoveredOrdinaryUnknown && !prepareOriginalComparison))
{
foreach (var item in copy.Pending.Values) copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
foreach (var item in copy.Unsent())
{
copy.Conflicts[item.Key] = PlayerKVValue.At(remote, item.Key);
}
return Commit(copy, PlayerKVSyncStatus.UnknownOutcome);
}
foreach (var item in copy.Pending.Values.ToArray())
var recoveringKeys = recoverImmediateView
? new HashSet<string>(copy.ImmediatePreservedKeys, StringComparer.Ordinal)
: new HashSet<string>(StringComparer.Ordinal);
if (recoverImmediateView)
{
foreach (var sequence in recoveredImmediate)
{
var source = copy.RemoteOnlyWrites[sequence];
Rebase(copy, source.Key, PlayerKVValue.At(remote, source.Key));
if (!prepareOriginalComparison)
{
copy.RemoteOnlyWrites.Remove(sequence);
}
}
if (!prepareOriginalComparison)
{
copy.ImmediatePreservedKeys.Clear();
}
}
foreach (var source in Sources(copy))
{
if (prepareOriginalComparison && copy.OrdinaryBuffers.Contains(source))
{
continue;
}
foreach (var item in source.Values.ToArray())
{
var observed = PlayerKVValue.At(remote, item.Key);
if (observed.Equals(item.Target))
{
source.Remove(item.Key);
}
else if (!observed.Equals(item.BaseValue))
{
copy.Conflicts[item.Key] = observed;
}
}
}
foreach (var item in copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
{
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;
if (!observed.Equals(item.Target) && !observed.Equals(item.BaseValue))
{
copy.Conflicts[item.Key] = observed;
}
}
copy.Baseline = remote.ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal);
var visible = new Dictionary<string, string>(remote, StringComparer.Ordinal);
foreach (var pair in copy.LocalOrigins.ToArray())
{
if (pair.Value.LocalOnly)
{
if (recoverImmediateView && copy.LegacyLocalOnlyUnderImmediate.TryGetValue(pair.Key, out var lower))
{
visible[pair.Key] = lower;
}
else
{
Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key));
}
}
else
{
var source = copy.Unsent().Concat(copy.PreparedOrdinaryBatches.SelectMany(batch => batch.Items))
.Concat(copy.OrdinaryUnknownBatches.SelectMany(batch => batch.Items))
.FirstOrDefault(item => item.Sequence == pair.Value.Sequence);
if (!recoverImmediateView)
{
Apply(visible, pair.Key, PlayerKVValue.At(copy.LocalData, pair.Key));
}
else if (source != null)
{
Apply(visible, pair.Key, recoveringKeys.Contains(pair.Key)
? source.Target : PlayerKVValue.At(copy.LocalData, pair.Key));
}
else
{
copy.LocalOrigins.Remove(pair.Key);
}
}
}
// An immediate write preserves its view until a new owner performs its initial Get.
foreach (var key in copy.ImmediatePreservedKeys)
{
if (prepareOriginalComparison && recoveringKeys.Contains(key))
{
continue;
}
Apply(visible, key, PlayerKVValue.At(copy.LocalData, key));
}
if (recoverImmediateView && !prepareOriginalComparison)
{
copy.LegacyLocalOnlyUnderImmediate.Clear();
}
if (!prepareOriginalComparison)
{
copy.LocalData = visible;
}
copy.Baseline = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
copy.HasBaseline = true;
return Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict);
var reconciled = Commit(copy, copy.Conflicts.Count == 0 ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.Conflict);
if (reconciled.Success)
{
recoverImmediateView = false;
if (prepareOriginalComparison)
{
comparisonLocal = original;
comparisonRemote = remote.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
comparisonTimes = updatedAtUtc.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
comparisonView = visible;
comparisonImmediateKeys = new HashSet<string>(StringComparer.Ordinal);
}
else
{
recoveredImmediate.Clear();
}
}
return reconciled;
}
}
private Dictionary<string, string> ReadView()
{
if (comparisonLocal == null)
{
return LocalView(snapshot);
}
var view = new Dictionary<string, string>(comparisonView, StringComparer.Ordinal);
foreach (var pair in snapshot.LocalOrigins)
{
if (pair.Value.Sequence >= comparisonLocal.NextSequence)
{
Apply(view, pair.Key, PlayerKVValue.At(snapshot.LocalData, pair.Key));
}
}
return view;
}
internal PlayerKVStateResult CompareDatas(long nowTicks)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
snapshot.Unknown != null || comparisonLocal == null)
{
return Blocked();
}
if ((comparisonLocal.OrdinaryUnknownBatches.Count != 0 && !recoveredOrdinaryUnknown) ||
snapshot.OrdinaryUnknownBatches.Any(batch => !comparisonLocal.OrdinaryUnknownBatches.Any(initial => initial.Id == batch.Id)))
{
return Blocked();
}
if (nowTicks < 0 || nowTicks > DateTime.MaxValue.Ticks)
{
return Invalid();
}
var copy = snapshot.Clone();
// A new login uses the original time comparison, not the old request's outcome.
// Retire only its initial ordinary evidence in the same commit as that comparison.
foreach (var batch in comparisonLocal.OrdinaryUnknownBatches)
{
copy.OrdinaryUnknownBatches.RemoveAll(current => current.Id == batch.Id);
foreach (var item in batch.Items)
{
if (copy.LocalOrigins.TryGetValue(item.Key, out var origin) && origin.Sequence == item.Sequence)
{
copy.LocalOrigins.Remove(item.Key);
}
}
}
var protectedKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var source in new[] { comparisonLocal, snapshot })
{
protectedKeys.UnionWith(source.PreparedOrdinaryBatches.SelectMany(batch => batch.Items).Select(item => item.Key));
protectedKeys.UnionWith(source.LegacyPending.Keys);
protectedKeys.UnionWith(source.ExplicitDeletes.Keys);
protectedKeys.UnionWith(source.ImmediatePreservedKeys);
protectedKeys.UnionWith(source.RemoteOnlyWrites.Values.Select(item => item.Key));
}
protectedKeys.UnionWith(snapshot.LocalOrigins.Where(pair => pair.Value.Sequence >= comparisonLocal.NextSequence).Select(pair => pair.Key));
var visible = ReadView();
foreach (var key in protectedKeys)
{
Apply(copy.LocalData, key, PlayerKVValue.At(visible, key));
}
foreach (var key in comparisonLocal.LocalData.Keys.Concat(comparisonRemote.Keys).Distinct(StringComparer.Ordinal))
{
if (protectedKeys.Contains(key))
{
continue;
}
var localExists = comparisonLocal.HasBaseline && comparisonLocal.LocalData.TryGetValue(key, out _);
comparisonLocal.LocalData.TryGetValue(key, out var localValue);
var remoteExists = comparisonRemote.TryGetValue(key, out var remoteValue);
var localTime = comparisonLocal.LocalUpdateTicks.TryGetValue(key, out var ticks) ? ticks : 0;
var remoteTime = remoteExists ? comparisonTimes[key].Ticks : 0;
if (!localExists || (remoteExists && remoteTime - localTime > TimeSpan.TicksPerSecond * 60))
{
copy.LocalData[key] = remoteValue;
copy.LocalUpdateTicks[key] = remoteTime;
foreach (var bucket in copy.OrdinaryBuffers)
{
if (bucket.TryGetValue(key, out var current) && comparisonLocal.OrdinaryBuffers.Any(old =>
old.TryGetValue(key, out var item) && item.Sequence == current.Sequence))
{
bucket.Remove(key);
if (copy.LocalOrigins.TryGetValue(key, out var origin) && origin.Sequence == current.Sequence)
{
copy.LocalOrigins.Remove(key);
}
}
}
}
else if (!remoteExists || localTime - remoteTime > TimeSpan.TicksPerSecond * 60)
{
if (copy.NextSequence == long.MaxValue)
{
return Invalid();
}
ApplyUpdate(copy, key, localValue, true, nowTicks);
}
else
{
copy.LocalData[key] = localValue;
copy.LocalUpdateTicks[key] = localTime;
}
}
foreach (var sequence in comparisonLocal.RemoteOnlyWrites.Keys)
{
copy.RemoteOnlyWrites.Remove(sequence);
}
foreach (var key in comparisonLocal.LegacyLocalOnlyUnderImmediate.Keys)
{
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
}
foreach (var key in comparisonLocal.ImmediatePreservedKeys)
{
if (!comparisonImmediateKeys.Contains(key))
{
copy.ImmediatePreservedKeys.Remove(key);
}
}
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
recoveredOrdinaryUnknown = false;
ClearComparison();
recoveredImmediate.Clear();
}
return result;
}
}
private void ClearComparison()
{
comparisonLocal = null;
comparisonRemote = null;
comparisonTimes = null;
comparisonView = null;
comparisonImmediateKeys = null;
}
public PlayerKVStateResult RecordReadFailure(bool unauthorized = false)
{
lock (gate)
@@ -98,42 +408,177 @@ namespace game.PlayerDataStorage
}
}
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);
public PlayerKVStateResult Update(string key, string value, bool remoteStore = true)
{
return Update(key, value, remoteStore, null);
}
PlayerKVStateResult Edit(string key, PlayerKVValue target, bool remote)
internal PlayerKVStateResult Update(string key, string value, bool remoteStore, long? updateTicks)
{
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)
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
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);
return Blocked();
}
else copy.LocalOnly[key] = target.Value;
if (!KVSnapshot.ValidKey(key) || value == null || snapshot.NextSequence == long.MaxValue ||
(updateTicks.HasValue && (updateTicks.Value < 0 || updateTicks.Value > DateTime.MaxValue.Ticks)))
{
return Invalid();
}
var copy = snapshot.Clone();
var sequence = ApplyUpdate(copy, key, value, remoteStore, updateTicks);
return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
}
}
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch)
private static long ApplyUpdate(KVSnapshot copy, string key, string value, bool remoteStore, long? updateTicks)
{
var sequence = copy.NextSequence++;
copy.LocalData[key] = value;
copy.ImmediatePreservedKeys.Remove(key);
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
if (updateTicks.HasValue)
{
copy.LocalUpdateTicks[key] = updateTicks.Value;
}
else
{
copy.LocalUpdateTicks.Remove(key);
}
var acceptedRemote = false;
if (remoteStore)
{
foreach (var bucket in copy.OrdinaryBuffers)
{
if (!bucket.ContainsKey(key) && bucket.Count >= 10)
{
continue;
}
var baseline = bucket.TryGetValue(key, out var previous)
? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
bucket[key] = new PlayerKVChange(key, sequence, baseline, PlayerKVValue.Present(value));
copy.LegacyPending.Remove(key);
copy.ExplicitDeletes.Remove(key);
acceptedRemote = true;
break;
}
}
copy.LocalOrigins[key] = (!acceptedRemote, sequence);
return sequence;
}
public PlayerKVStateResult Delete(string key)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (!KVSnapshot.ValidKey(key) || snapshot.NextSequence == long.MaxValue)
{
return Invalid();
}
var copy = snapshot.Clone();
var sequence = copy.NextSequence++;
var prior = copy.ExplicitDeletes.TryGetValue(key, out var previous) ? previous.BaseValue : PlayerKVValue.At(copy.Baseline, key);
RemoveSources(copy, key);
copy.ExplicitDeletes[key] = new PlayerKVChange(key, sequence, prior, PlayerKVValue.Missing);
copy.LocalData.Remove(key);
copy.LocalUpdateTicks.Remove(key);
copy.LocalOrigins[key] = (false, sequence);
copy.ImmediatePreservedKeys.Remove(key);
copy.LegacyLocalOnlyUnderImmediate.Remove(key);
return Commit(copy, PlayerKVSyncStatus.Ready, sequence);
}
}
internal PlayerKVStateResult CaptureOrdinaryBatch(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);
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (snapshot.OrdinaryBuffers[0].Count == 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 first = copy.OrdinaryBuffers[0];
var captured = new PlayerKVBatch(Guid.NewGuid().ToString("N"), first.Values.OrderBy(item => item.Sequence), true);
copy.PreparedOrdinaryBatches.Add(captured);
copy.OrdinaryBuffers.RemoveAt(0);
copy.OrdinaryBuffers.Add(new Dictionary<string, PlayerKVChange>(StringComparer.Ordinal));
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success) batch = snapshot.Active; // Never expose a batch until its write-ahead record is durable.
if (result.Success)
{
batch = captured;
}
return result;
}
}
internal PlayerKVStateResult BeginOrdinaryBatch(out PlayerKVBatch batch)
{
lock (gate)
{
batch = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.Active != null || snapshot.Unknown != null || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (snapshot.PreparedOrdinaryBatches.Count == 0)
{
return new PlayerKVStateResult(PlayerKVChangeError.None, revision: snapshot.Revision);
}
var copy = snapshot.Clone();
copy.Active = copy.PreparedOrdinaryBatches[0];
copy.PreparedOrdinaryBatches.RemoveAt(0);
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
batch = snapshot.Active;
}
return result;
}
}
// Explicit requests retain their original source until the outcome is known. Ordinary buckets are never selected here.
public PlayerKVStateResult BeginBatch(long throughSequence, out PlayerKVBatch batch)
{
return BeginBatch(throughSequence, false, out batch);
}
internal PlayerKVStateResult BeginBatch(long throughSequence, bool legacyOnly, 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 source = legacyOnly ? snapshot.LegacyPending.Values : snapshot.LegacyPending.Values.Concat(snapshot.ExplicitDeletes.Values);
var ordered = source.Where(item => item.Sequence <= throughSequence).OrderBy(item => item.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(item => item.Target.Exists == ordered[0].Target.Exists).Take(10));
var result = Commit(copy, PlayerKVSyncStatus.Ready);
if (result.Success)
{
batch = snapshot.Active;
}
return result;
}
}
@@ -142,35 +587,204 @@ namespace game.PlayerDataStorage
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked();
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);
if (!copy.Active.IsOrdinary)
{
foreach (var source in new[] { copy.LegacyPending, copy.ExplicitDeletes })
{
if (source.TryGetValue(item.Key, out var current) && current.Sequence == item.Sequence)
{
source.Remove(item.Key);
}
}
}
Rebase(copy, item.Key, item.Target);
}
copy.Active = null;
return Commit(copy, PlayerKVSyncStatus.Ready);
}
}
// mayHaveCommitted=false is only valid with positive evidence of no dispatch.
// A definitely undispatched ordinary batch retires, as the original SDK call did; explicit source references stay.
public PlayerKVStateResult FailBatch(string batchId, bool mayHaveCommitted, bool unauthorized = false)
{
lock (gate)
{
if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId) return Blocked();
if (Terminal || snapshot.Active == null || snapshot.Active.Id != batchId)
{
return Blocked();
}
var copy = snapshot.Clone();
var ordinary = copy.Active.IsOrdinary;
copy.AuthenticationInvalid |= unauthorized;
if (mayHaveCommitted) copy.Unknown = copy.Active;
if (mayHaveCommitted)
{
if (ordinary)
{
copy.OrdinaryUnknownBatches.Add(copy.Active);
}
else
{
copy.Unknown = copy.Active;
}
}
copy.Active = null;
return Commit(copy, mayHaveCommitted ? PlayerKVSyncStatus.UnknownOutcome :
copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : PlayerKVSyncStatus.ReadFailed);
var next = copy.AuthenticationInvalid ? PlayerKVSyncStatus.Unauthorized : ordinary ? PlayerKVSyncStatus.Ready : PlayerKVSyncStatus.ReadFailed;
if (copy.Unknown != null)
{
next = PlayerKVSyncStatus.UnknownOutcome;
}
return Commit(copy, next);
}
}
private static IEnumerable<Dictionary<string, PlayerKVChange>> Sources(KVSnapshot value)
{
return value.OrdinaryBuffers.Concat(new[] { value.LegacyPending, value.ExplicitDeletes });
}
private static void RemoveSources(KVSnapshot value, string key)
{
foreach (var source in Sources(value))
{
source.Remove(key);
}
}
private static void Rebase(KVSnapshot value, string key, PlayerKVValue baseline)
{
foreach (var source in Sources(value))
{
if (source.TryGetValue(key, out var pending))
{
source[key] = pending.WithBase(baseline);
}
}
}
internal PlayerKVStateResult PrepareImmediate(string key, string value, out PlayerKVChange operation)
{
operation = null;
if (!KVSnapshot.ValidKey(key) || value == null)
{
// Preserve the single-key state admission order and validation result.
lock (gate)
{
return status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid ? Blocked() : Invalid();
}
}
var result = PrepareImmediate(new Dictionary<string, string> { [key] = value }, out var operations);
if (result.Success)
{
operation = operations[0];
}
return result;
}
internal PlayerKVStateResult PrepareImmediate(IReadOnlyDictionary<string, string> data,
out IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
operations = null;
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid)
{
return Blocked();
}
if (data == null || data.Count == 0 || data.Count > long.MaxValue - snapshot.NextSequence ||
data.Any(pair => !KVSnapshot.ValidKey(pair.Key) || pair.Value == null))
{
return Invalid();
}
var copy = snapshot.Clone();
var prepared = new List<PlayerKVChange>();
foreach (var pair in data)
{
copy.ImmediatePreservedKeys.Add(pair.Key);
var operation = new PlayerKVChange(pair.Key, copy.NextSequence++,
PlayerKVValue.At(copy.Baseline, pair.Key), PlayerKVValue.Present(pair.Value));
copy.RemoteOnlyWrites.Add(operation.Sequence, operation);
RemoveSources(copy, pair.Key);
prepared.Add(operation);
}
var result = Commit(copy, PlayerKVSyncStatus.Ready, prepared[0].Sequence);
if (result.Success)
{
operations = prepared.AsReadOnly();
if (comparisonLocal != null)
{
comparisonImmediateKeys.UnionWith(data.Keys);
}
}
return result;
}
}
internal PlayerKVStateResult CompleteImmediate(PlayerKVChange operation)
{
return CompleteImmediate(new[] { operation });
}
internal PlayerKVStateResult CompleteImmediate(IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
if (status != PlayerKVSyncStatus.Ready || snapshot.AuthenticationInvalid || snapshot.Active != null ||
snapshot.Unknown != null || operations.Any(operation => !snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence)))
{
return Blocked();
}
var copy = snapshot.Clone();
foreach (var operation in operations)
{
Apply(copy.Baseline, operation.Key, operation.Target);
Rebase(copy, operation.Key, operation.Target);
copy.RemoteOnlyWrites.Remove(operation.Sequence);
copy.ImmediatePreservedKeys.Add(operation.Key);
}
return Commit(copy, PlayerKVSyncStatus.Ready);
}
}
internal void CancelUndispatchedImmediate(PlayerKVChange operation)
{
CancelUndispatchedImmediate(new[] { operation });
}
internal void CancelUndispatchedImmediate(IReadOnlyList<PlayerKVChange> operations)
{
lock (gate)
{
if (Terminal)
{
return;
}
var protectedBatches = snapshot.OrdinaryUnknownBatches.Concat(new[] { snapshot.Active, snapshot.Unknown }).Where(batch => batch != null);
var removable = operations.Where(operation => snapshot.RemoteOnlyWrites.ContainsKey(operation.Sequence) &&
!protectedBatches.Any(batch => batch.Items.Any(item => item.Key == operation.Key && item.Sequence < operation.Sequence))).ToArray();
if (removable.Length == 0)
{
return;
}
var copy = snapshot.Clone();
foreach (var operation in removable)
{
copy.RemoteOnlyWrites.Remove(operation.Sequence);
}
Commit(copy, status);
}
}
private static Dictionary<string, string> LocalView(KVSnapshot source)
{
return new Dictionary<string, string>(source.LocalData, StringComparer.Ordinal);
}
bool Terminal => status == PlayerKVSyncStatus.Closed || status == PlayerKVSyncStatus.StorageError;
PlayerKVStateResult Commit(KVSnapshot copy, PlayerKVSyncStatus nextStatus, long sequence = 0)
{
@@ -196,6 +810,7 @@ namespace game.PlayerDataStorage
{
if (status == PlayerKVSyncStatus.Closed) return;
status = PlayerKVSyncStatus.Closed;
ClearComparison();
store.Dispose();
}
}

View File

@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -8,11 +8,10 @@ namespace game.PlayerDataStorage
{
public sealed class KVPlayerSaveStore : IPlayerSaveStore, IDisposable
{
PlayerKVGameSessionHandle handle;
CancellationToken lifetime;
bool bound, disposed;
readonly object receiptsGate = new object();
readonly Dictionary<string, PlayerKVSyncTicket> pendingReceipts = new Dictionary<string, PlayerKVSyncTicket>(StringComparer.Ordinal);
private PlayerKVGameSessionHandle handle;
private CancellationToken lifetime;
private bool bound, disposed;
private readonly object receiptsGate = new object();
public bool IsCurrent => bound && !disposed && !lifetime.IsCancellationRequested && handle.Status != PlayerKVSyncStatus.Closed;
internal void Bind(PlayerKVGameSessionHandle original, CancellationToken loginLifetime)
@@ -27,6 +26,23 @@ namespace game.PlayerDataStorage
bound = true;
}
internal void ReleaseFailedBinding(PlayerKVGameSessionHandle original)
{
lock (receiptsGate)
{
if (!bound)
{
return;
}
if (disposed || !ReferenceEquals(handle, original) || original.Status != PlayerKVSyncStatus.Closed)
{
throw new InvalidOperationException("Failed save binding has not closed.");
}
handle = null;
bound = false;
}
}
public PlayerSaveReadResult Read(string key)
{
if (!IsCurrent) return new PlayerSaveReadResult("StaleOrNotReady");
@@ -45,94 +61,92 @@ namespace game.PlayerDataStorage
return rejected;
}
var result = KVMgr.WriteKV(handle, key, value, remoteStore);
if (result.Accepted && result.Ticket != null) pendingReceipts[key] = result.Ticket;
var write = result.Accepted ? new PlayerSaveWriteResult(new PlayerSaveReceipt(this, result.Ticket)) :
new PlayerSaveWriteResult(result.StateResult.Error.ToString());
return write;
}
}
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default)
public async Task<bool> SyncDataImmidiate(string key, string value)
{
if (token.IsCancellationRequested) return Cancelled();
var write = Write(key, value);
if (!write.Accepted) return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, write.Error);
return await ConfirmAsync(write.Receipt, token);
}
public async Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested) return Cancelled();
if (receipt == null || !ReferenceEquals(receipt.Store, this))
return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, "ForeignReceipt");
if (receipt.Ticket == null) return new PlayerSaveConfirmation(PlayerSaveStatus.LocalOnly);
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
PlayerKVGameSessionHandle original;
CancellationToken originalLifetime;
Task<bool> save;
lock (receiptsGate)
{
try
if (!IsCurrent)
{
await KVMgr.FlushKVAsync(handle, wait.Token);
var ticket = await KVMgr.WaitForKVTicketAsync(handle, (PlayerKVSyncTicket)receipt.Ticket, wait.Token);
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
if (ticket.Status == PlayerKVTicketStatus.Confirmed)
{
ForgetConfirmed((PlayerKVSyncTicket)receipt.Ticket);
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
}
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
ticket.TransportError.ToString());
return false;
}
catch (OperationCanceledException) { return Cancelled(); }
// Preserve the former current-store dictionary lookup's null-key behavior.
if (key == null)
{
throw new ArgumentNullException(nameof(key));
}
original = handle;
originalLifetime = lifetime;
save = KVMgr.SyncDataImmidiate(original, key, value);
}
var success = await save;
lock (receiptsGate)
{
return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original);
}
}
public async Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default)
internal async Task<bool> SyncDataImmidiate(IReadOnlyDictionary<string, string> values)
{
if (!IsCurrent || token.IsCancellationRequested) return Cancelled();
PlayerKVSyncTicket[] captured;
lock (receiptsGate) captured = pendingReceipts.Values.ToArray();
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
PlayerKVGameSessionHandle original;
CancellationToken originalLifetime;
Task<bool> save;
lock (receiptsGate)
{
try
if (!IsCurrent || values == null || values.Count == 0)
{
var result = await KVMgr.FlushKVAsync(handle, wait.Token);
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
var status = result.Unknown > 0 || result.CacheStatus == PlayerKVSyncStatus.UnknownOutcome ?
PlayerSaveStatus.Unknown : result.Blocked > 0 || result.TransportError != PlayerKVError.None ||
result.CacheStatus != PlayerKVSyncStatus.Ready ? PlayerSaveStatus.Rejected : PlayerSaveStatus.Confirmed;
if (status != PlayerSaveStatus.Confirmed) return new PlayerSaveConfirmation(status, result.TransportError.ToString());
// Multiple writes before this flush are covered by the captured final receipt.
// A replacement during the await belongs to a later flush and cannot confirm it.
foreach (var receipt in captured)
{
var ticket = await KVMgr.WaitForKVTicketAsync(handle, receipt, wait.Token);
if (!IsCurrent || wait.IsCancellationRequested) return Cancelled();
if (ticket.Status != PlayerKVTicketStatus.Confirmed)
return new PlayerSaveConfirmation(ticket.Status == PlayerKVTicketStatus.Superseded ? PlayerSaveStatus.Superseded :
ticket.Status == PlayerKVTicketStatus.Unknown ? PlayerSaveStatus.Unknown :
ticket.Status == PlayerKVTicketStatus.Cancelled ? PlayerSaveStatus.Cancelled : PlayerSaveStatus.Rejected,
ticket.TransportError.ToString());
ForgetConfirmed(receipt);
}
return new PlayerSaveConfirmation(PlayerSaveStatus.Confirmed);
return false;
}
catch (OperationCanceledException) { return Cancelled(); }
var data = values.ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
original = handle;
originalLifetime = lifetime;
save = KVMgr.SyncDataImmidiate(original, data);
}
var success = await save;
lock (receiptsGate)
{
return success && IsCurrent && !originalLifetime.IsCancellationRequested && ReferenceEquals(handle, original);
}
}
static PlayerSaveConfirmation Cancelled() => new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
void ForgetConfirmed(PlayerKVSyncTicket receipt)
internal Task<PlayerKVFlushResult> SyncDataAsync()
{
lock (receiptsGate)
if (pendingReceipts.TryGetValue(receipt.Key, out var latest) && ReferenceEquals(latest, receipt))
pendingReceipts.Remove(receipt.Key);
{
var original = handle;
if (!IsCurrent)
{
return Task.FromResult(new PlayerKVFlushResult(0, 0, 1, 0, PlayerKVError.Cancelled,
original == null ? PlayerKVSyncStatus.NotLoaded : original.Status));
}
return KVMgr.SyncKVAsync(original);
}
}
public void Dispose()
{
lock (receiptsGate)
{
disposed = true;
}
}
public void Dispose() { disposed = true; lock (receiptsGate) { pendingReceipts.Clear(); } }
}
public sealed class PlayFabPlayerSaveStore : IPlayerSaveStore
{
public Task<bool> SyncDataImmidiate(string key, string value)
{
return manager.SyncDataImmidiate(key, value);
}
readonly PlayFabMgr manager;
readonly CancellationToken lifetime;
public PlayFabPlayerSaveStore(IUserService user) : this(PlayFabMgr.Instance, user.LoginLifetime) { }
@@ -162,38 +176,5 @@ namespace game.PlayerDataStorage
catch (Exception ex) { return new PlayerSaveWriteResult(ex.GetType().Name); }
}
public async Task<PlayerSaveConfirmation> WriteAndConfirmAsync(string key, string value, CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
using (var wait = CancellationTokenSource.CreateLinkedTokenSource(lifetime, token))
{
var cancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
using (wait.Token.Register(() => cancelled.TrySetResult(false)))
{
try
{
var save = manager.SyncDataImmidiate(key, value);
var finished = await Task.WhenAny(save, cancelled.Task);
if (!IsCurrent || wait.IsCancellationRequested) return new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled);
return new PlayerSaveConfirmation(await finished ? PlayerSaveStatus.Confirmed : PlayerSaveStatus.Rejected);
}
catch (Exception ex) { return new PlayerSaveConfirmation(PlayerSaveStatus.Rejected, ex.GetType().Name); }
}
}
}
public Task<PlayerSaveConfirmation> ConfirmAsync(PlayerSaveReceipt receipt, CancellationToken token = default) =>
Task.FromResult(new PlayerSaveConfirmation(!IsCurrent || token.IsCancellationRequested ? PlayerSaveStatus.Cancelled :
receipt == null || !ReferenceEquals(receipt.Store, this) ? PlayerSaveStatus.Rejected :
receipt.Ticket == null ? PlayerSaveStatus.LocalOnly : PlayerSaveStatus.Buffered));
public Task<PlayerSaveConfirmation> FlushAsync(CancellationToken token = default)
{
if (!IsCurrent || token.IsCancellationRequested)
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Cancelled));
// The original manager owns its timer and callback-based buffered dispatch.
manager.FlushBufferedData();
return Task.FromResult(new PlayerSaveConfirmation(PlayerSaveStatus.Buffered));
}
}
}

View File

@@ -68,19 +68,34 @@ namespace game
}
internal bool HasFormalPromptOwner(PlayerKVGameSessionHandle owner) =>
requiresFormalKV && ReferenceEquals(promptOwner, owner);
internal void ReleaseFailedPromptOwner(PlayerKVGameSessionHandle owner)
{
if (promptOwner == null)
{
return;
}
if (!ReferenceEquals(promptOwner, owner) || owner.Status != PlayerKVSyncStatus.Closed)
{
throw PromptFailure("ShowNotice: failed owner has not closed.");
}
promptOwner = null;
LastPromptEdit = null;
_showNotice = null;
}
internal void RequirePromptOwner()
{
if (promptOwner != null) ReadPrompt();
else if (requiresFormalKV) throw PromptFailure("ShowNotice: formal owner not handed off.");
}
PlayerKVKeyReadResult ReadPrompt()
private PlayerKVKeyReadResult ReadPrompt()
{
if (LastPromptEdit != null && !LastPromptEdit.Accepted)
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
var read = KVMgr.ReadKV(promptOwner, "ShowNotice");
if (!read.Success)
{
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {read.Error}");
}
return read;
}
@@ -162,11 +177,14 @@ namespace game
{
get
{
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null)
{
var read = ReadPrompt();
_showNotice = read.Found == true && read.Value == "1";
_showNotice ??= read.Found == true && read.Value == "1";
return _showNotice.Value;
}
_showNotice ??= PlayFabMgr.Instance.GetLocalData("ShowNotice") == "1";
@@ -174,19 +192,23 @@ namespace game
}
set
{
if (requiresFormalKV && promptOwner == null) RequirePromptOwner();
if (requiresFormalKV && promptOwner == null)
{
RequirePromptOwner();
}
if (promptOwner != null)
{
var read = ReadPrompt();
var serialized = value ? "1" : "0";
if (read.Found != true || read.Value != serialized)
ReadPrompt();
if (value != _showNotice)
{
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", serialized);
_showNotice = value;
LastPromptEdit = KVMgr.WriteKV(promptOwner, "ShowNotice", value ? "1" : "0");
if (!LastPromptEdit.Accepted)
{
throw PromptFailure($"ShowNotice owner={promptOwner.OwnerInstanceId}: {LastPromptEdit.StateResult.Error}; uncertain={LastPromptEdit.StateResult.LocalCommitMayHaveSucceeded}");
}
}
RequirePromptOwner();
_showNotice = value;
return;
}
if (value != _showNotice)

View File

@@ -25,7 +25,9 @@ namespace game
{
titleNewsEvent.titleNewsItem = News;
bool isRod = false;
GetTitleNewsResult result = await PlayFabClientAsyncAPI.GetTitleNewsAsync(new PlayFab.ClientModels.GetTitleNewsRequest());
GetTitleNewsResult result = GContext.container.Resolve<IUserService>() is KVUserService
? await GetKVTitleNewsAsync()
: await PlayFabClientAsyncAPI.GetTitleNewsAsync(new PlayFab.ClientModels.GetTitleNewsRequest());
if (result != null && result.News != null && result.News.Count > 0)
{
News = result.News[0];
@@ -57,5 +59,34 @@ namespace game
RedPointManager.Instance.SetRedPointState(RedPointName.Announcement, isRod);
}
private async Task<GetTitleNewsResult> GetKVTitleNewsAsync()
{
string body = await GContext.container.Resolve<ICustomServerMgr>().CommonDataRequest("GetTitleNews", new { });
if (string.IsNullOrEmpty(body))
{
return null;
}
try
{
var response = Newtonsoft.Json.JsonConvert.DeserializeObject<KvTitleNewsResponse>(body);
if (response == null || response.Code != 1 || response.News == null ||
(response.News.Count > 0 && response.News[0] == null))
{
return null;
}
return new GetTitleNewsResult { News = response.News };
}
catch (Newtonsoft.Json.JsonException ex)
{
Debug.LogWarning("KV title news response: " + ex.GetType().Name);
return null;
}
}
private sealed class KvTitleNewsResponse
{
public int Code;
public System.Collections.Generic.List<TitleNewsItem> News;
}
}
}

View File

@@ -1,4 +1,4 @@
#if UNITY_EDITOR && !SERVICE_HOST
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.Globalization;
@@ -152,9 +152,6 @@ namespace game.PlayerDataStorage
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)
@@ -187,7 +184,6 @@ namespace game.PlayerDataStorage
}
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;
@@ -222,17 +218,6 @@ namespace game.PlayerDataStorage
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;
@@ -253,9 +238,6 @@ namespace game.PlayerDataStorage
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{
var owner=a.Consumption.Owner;
var flush=await KVMgr.FlushKVAsync(owner,timeout.Token);
if(!RewardCurrent(a) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
throw Stop("Reward flush: "+flush.TransportError);
foreach(var r in a.Records)
{
var t=r.Edit.Ticket;if(t==null)continue;

View File

@@ -1,4 +1,4 @@
#if UNITY_EDITOR && !SERVICE_HOST
#if UNITY_EDITOR
using System;
using System.Collections.Generic;
using System.IO;
@@ -9,287 +9,691 @@ using asap.core;
using GameCore;
using Newtonsoft.Json;
using UniRx;
using DG.Tweening;
using UnityEngine;
namespace game.PlayerDataStorage
{
internal static partial class PlayerKVGameplayLoop
{
// Observes the original async-void method; never enables the temporary gameplay path.
internal static async Task<string> VerifyOriginalFishingForEditorAsync()
{
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 oldContainer = GContext.container;
var oldEvents = FishingContextAccess.Events;
var oldLanguages = LocalizationMgr.enumLanguage;
var fields = System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic;
var textTable = typeof(LocalizationMgr).GetField("table", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
var oldTextTable = textTable.GetValue(null);
var folder = Path.Combine(Root, "fishing-original-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
var passed = new List<string>();
int checks = 0;
void Check(bool value, string message)
{
checks++;
if (!value)
{
throw new InvalidOperationException("Original fishing: " + message);
}
}
try
{
foreach (var scenario in new[] { "ordinary", "retry", "pack-21", "pack-22", "unauthorized", "held-recovery", "below-limit", "subscribers", "cross-limit-boundary" })
{
var errors = new List<Exception>();
var fixture = new EquipmentFixture(Path.Combine(folder, scenario));
KVPlayerSaveStore store = null;
Container container = null;
PlayerData player = null;
AchievementDataManager achievements = null;
FishingSubscribers subscribers = null;
GameObject shell = null;
IDisposable consumeSubscription = null;
IDisposable playerSubscription = null;
bool invoked = false;
int completionLogs = 0;
int saveFailureLogs = 0;
var completed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var unexpectedLogs = new List<string>();
void Log(string condition, string stack, LogType type)
{
const string prefix = "[FishBehaviourB::ConsumeBaitOnPlayfab] ";
if (condition == prefix + "True" || condition == prefix + "False")
{
completionLogs++;
completed.TrySetResult(condition == prefix + "True");
}
if (scenario == "unauthorized" && type == LogType.Error && condition == "[KVMgr::SyncDataImmidiate] Save failed.")
{
saveFailureLogs++;
}
else if (type == LogType.Exception || type == LogType.Error || type == LogType.Assert)
{
unexpectedLogs.Add(condition + "\n" + stack);
}
}
void Clean(Action action)
{
try
{
action();
}
catch (Exception ex)
{
errors.Add(ex);
validationCleanupFailure = fixture;
}
}
void ObserveRecovery(Action startRecovery)
{
var awaitAssembly = typeof(IEnumeratorAwaitExtensions).Assembly;
var staticFields = System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic;
var allFields = fields | System.Reflection.BindingFlags.Public;
var syncField = awaitAssembly.GetType("UnityAsyncAwaitUtil.SyncContextUtil", true).GetField("<UnitySynchronizationContext>k__BackingField", staticFields);
var runnerField = awaitAssembly.GetType("UnityAsyncAwaitUtil.AsyncCoroutineRunner", true).GetField("_instance", staticFields);
var savedSync = syncField.GetValue(null);
var savedRunner = runnerField.GetValue(null);
var savedCurrent = SynchronizationContext.Current;
var observer = new OriginalFishingTestContext();
var scheduler = new OriginalFishingTestContext();
System.Collections.IEnumerator instructionRun = null;
IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter recoveryAwaiter = null;
bool instructionYielded = false;
bool instructionEnded = false;
try
{
SynchronizationContext.SetSynchronizationContext(observer);
syncField.SetValue(null, scheduler);
startRecovery();
Check(scheduler.Count == 1 && observer.Started == 1 && observer.Completed == 0, "original recovery suspended at one instruction");
var posted = scheduler.Peek();
Check(posted.State == null && posted.Callback.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass12_0", "exact original scheduler dispatch");
var action = (Action)posted.Callback.Target.GetType().GetField("action", allFields).GetValue(posted.Callback.Target);
Check(action.Target.GetType().FullName == "IEnumeratorAwaitExtensions+<>c__DisplayClass10_0" && action.Method.Name == "<GetAwaiterReturnVoid>b__0", "exact original void wait closure");
recoveryAwaiter = (IEnumeratorAwaitExtensions.SimpleCoroutineAwaiter)action.Target.GetType().GetField("awaiter", allFields).GetValue(action.Target);
var instruction = action.Target.GetType().GetField("instruction", allFields).GetValue(action.Target);
Check(instruction is WaitForSeconds && !recoveryAwaiter.IsCompleted, "actual incomplete seconds awaiter");
var wrapper = typeof(IEnumeratorAwaitExtensions).GetNestedType("InstructionWrappers", System.Reflection.BindingFlags.NonPublic);
instructionRun = (System.Collections.IEnumerator)wrapper.GetMethod("ReturnVoid", staticFields | System.Reflection.BindingFlags.Public).Invoke(null, new[] { (object)recoveryAwaiter, instruction });
scheduler.Take();
instructionYielded = instructionRun.MoveNext();
Check(instructionYielded && ReferenceEquals(instructionRun.Current, instruction), "library iterator yielded original instruction");
player.StopRecoveryEnergy();
SynchronizationContext.SetSynchronizationContext(scheduler);
instructionEnded = !instructionRun.MoveNext();
Check(instructionEnded && recoveryAwaiter.IsCompleted && observer.Completed == 1, "manual-await-boundary exits original async void");
}
finally
{
Clean(() => player.StopRecoveryEnergy());
Clean(() =>
{
SynchronizationContext.SetSynchronizationContext(scheduler);
if (instructionRun != null && !instructionEnded)
{
if (!instructionYielded)
{
instructionYielded = instructionRun.MoveNext();
}
instructionEnded = !instructionRun.MoveNext();
}
});
Clean(() =>
{
SynchronizationContext.SetSynchronizationContext(observer);
while (observer.Count != 0)
{
var posted = observer.Take();
Clean(() => posted.Callback(posted.State));
}
});
Clean(() => Check(observer.Started == 1 && observer.Completed == 1 && observer.Count == 0 && scheduler.Count == 0 && instructionEnded && recoveryAwaiter != null && recoveryAwaiter.IsCompleted, "recovery state machine and queues drained"));
Clean(() => Check(ReferenceEquals(runnerField.GetValue(null), savedRunner), "no coroutine runner created or replaced"));
Clean(() => syncField.SetValue(null, savedSync));
Clean(() => SynchronizationContext.SetSynchronizationContext(savedCurrent));
}
}
Application.logMessageReceived += Log;
try
{
var owner = await fixture.Open();
store = new KVPlayerSaveStore();
store.Bind(owner, CancellationToken.None);
var tables = new cfg.Tables(n => SimpleJSON.JSON.Parse(File.ReadAllText(Path.GetFullPath(Path.Combine(Application.dataPath, "../../cfgData", n + ".json")))));
Check(tables.TbEnergyDef.DataList.Count > 4 && tables.TbEnergyDef.DataList[4].Mag == 10 && tables.TbEnergyDef.DataList[4].RetryCost == 5, "distinct real normal/retry costs");
container = new Container();
FishingContextAccess.Use(container);
FishingContextAccess.Events = new EventAggregator();
container.RegisterInstance(tables);
container.RegisterInstance<IPlayerSaveStore>(store);
player = new PlayerData(tables, null);
var small = new SmallGameData(tables, null, null);
var fish = new PlayerFishData(tables, null);
achievements = new AchievementDataManager(tables);
var events = new FishingEventData(tables);
container.RegisterInstance(player);
container.RegisterInstance(small);
container.RegisterInstance(fish);
container.RegisterInstance(achievements);
container.RegisterInstance(new FishingBoxData());
container.RegisterInstance(events);
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
player.playerSaveStore = store;
fish.playerSaveStore = store;
small.playerSaveStore = store;
events.playerSaveStore = store;
achievements.Init();
LocalizationMgr.enumLanguage = tables.TbLanguageConfig.DataList.Select(x => x.ID).ToList();
textTable.SetValue(null, null);
int initialEnergy = scenario == "cross-limit-boundary" ? 300 : scenario == "held-recovery" || scenario == "below-limit" ? 250 : 400;
int rod = tables.TbRodData.DataMap.Keys.First();
int map = tables.TbMapData.DataMap.Keys.First();
Module02OriginalLoadTests.LoadCore(player, new object[] { 1, 10UL, 0, initialEnergy, 5, 4, rod, new DateTime(2026, 1, 1), 0, 7, 10UL, 2, map, map, 0, 0, 0, 0, 0 });
typeof(PlayerData).GetProperty("benefitsEnergyLimit").SetValue(player, 300);
typeof(PlayerData).GetProperty("benefitsEnergyRecover").SetValue(player, 1);
if (scenario == "subscribers")
{
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 configuration excludes additional async event and pack activation");
}
player.SavePlayerData(false);
int subtype = scenario == "pack-21" ? 21 : scenario == "pack-22" ? 22 : 1;
var item = tables.TbItem.DataList.FirstOrDefault(x => x.SubType == subtype);
Check(item != null, "real item subtype " + subtype);
var data = new FishingData { fishItem = item, IsRetry = scenario == "retry" };
int cost = scenario == "retry" ? 5 : 10;
int energyRM = player.GetEnergyRM();
var expected = new Dictionary<string, string>(player.PlayerDataDic);
expected["energy"] = (initialEnergy - cost).ToString();
string expectedJson = JsonConvert.SerializeObject(expected);
var order = new List<string>();
int consumed = 0;
playerSubscription = GContext.OnEvent<PlayerData>().Subscribe(_ => order.Add("player"));
consumeSubscription = GContext.OnEvent<ConditionTypeEvent>().Subscribe(e =>
{
if (e.type == cfg.ConditionType.ConsumeEnergy)
{
Check(player.Energy == initialEnergy - cost, "event observes deducted energy");
order.Add("consume");
consumed += e.count;
store.Write("k", "event-" + e.count).RequireAccepted();
}
});
if (scenario == "cross-limit-boundary")
{
ObserveRecovery(() => player.ReduceEnergy(cost, false));
Check(player.Energy == 290 && player.GetEnergyRM() == energyRM + cost, "original crossing deducts once");
Check(order.SequenceEqual(new[] { "player", "player" }) && consumed == 0, "crossing publishes twice without inventing consume event");
var crossingData = new Dictionary<string, string>(player.PlayerDataDic);
Check(crossingData["newData"].Split(',')[3] == "290" && DateTime.Parse(crossingData["newData"].Split(',')[7]) > new DateTime(2026, 1, 1) && !crossingData.ContainsKey("energy"), "original crossing save has updated time and energy");
Check(fixture.Sets == 0 && fixture.Remote["NewPlayerDataDic"] == "original-save", "crossing uses ordinary save without immediate network");
var flushed = await KVMgr.SyncKVAsync(owner);
Check(flushed.TransportError == PlayerKVError.None && flushed.Blocked == 0 && flushed.Unknown == 0 && fixture.Sets == 1 && fixture.Auth == 1, "crossing ordinary save succeeds once");
Check(fixture.Remote["NewPlayerDataDic"] == JsonConvert.SerializeObject(crossingData), "crossing exact original serialized snapshot");
var reloaded = new PlayerData(tables, null);
Module02OriginalLoadTests.LoadCore(reloaded, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
Check(reloaded.Energy == player.Energy && reloaded.equipRodID == player.equipRodID && (ulong)reloaded.gold == (ulong)player.gold, "crossing original parser retains energy rod and gold");
}
else
{
shell = new GameObject("KV original consumption fixed fixture");
shell.SetActive(false);
shell.hideFlags = HideFlags.HideAndDontSave;
var behavior = shell.AddComponent<FishingBehaviorB>();
typeof(FishingBehaviorB).GetField("_tables", fields).SetValue(behavior, tables);
typeof(FishingBehaviorB).GetField("fishingData", fields).SetValue(behavior, data);
typeof(FishingBehaviorB).GetField("saveStore", fields).SetValue(behavior, store);
Dictionary<string, string> subscriberWrites = null;
fixture.Unauthorized = scenario == "unauthorized";
fixture.HoldSet = scenario == "held-recovery";
invoked = true;
typeof(FishingBehaviorB).GetMethod("ConsumeBaitOnPlayfab", fields).Invoke(behavior, null);
Check(player.Energy == initialEnergy - cost && player.GetEnergyRM() == energyRM + cost, "one original local deduction");
Check(consumed == (subtype == 1 ? cost : 0), "original pack exclusion");
Check(order.SequenceEqual(subtype == 1 ? new[] { "player", "consume" } : new[] { "player" }), "publish before event");
if (subscribers != null)
{
Check(subscribers.Battle.Data.BattleTask.dic_dailyTasks.Values.Single() == cost && subscribers.Lucky.DicTasks.Values.Single() == cost && subscribers.Magic.DicTasks.Values.Single() == cost && subscribers.Mini.Data.Exp == cost, "four actual subscribers process one original consume event");
Check(achievements.GetAchievementDatas(cfg.ConditionType.ConsumeEnergy) == (ulong)cost, "actual achievement receives original event");
subscriberWrites = new[] { "BattlePassRecordTask", "MiniBPKeyNew", "EventLuckGameRecord", "EventLuckMagicPlayfabData" }.ToDictionary(key => key, key => store.Read(key).Value);
Check(subscriberWrites.Values.All(value => !string.IsNullOrEmpty(value)) && subscriberWrites.Keys.All(key => !fixture.Remote.ContainsKey(key)), "actual task payloads wait for ordinary save timing");
}
if (fixture.HoldSet)
{
Check(await Task.WhenAny(fixture.SetEntered.Task, Task.Delay(5000)) == fixture.SetEntered.Task, "held request entered");
Check(!completed.Task.IsCompleted && completionLogs == 0, "async void returned while actual request pending");
ObserveRecovery(() => player.CoRecoveryEnergy(tables.TbGlobalConfig.InitEnergyRestorationSpeed * 60));
Check(player.Energy == initialEnergy - cost + 1, "real recovery proceeds during request");
fixture.ReleaseSet.TrySetResult(true);
}
Check(await Task.WhenAny(completed.Task, Task.Delay(5000)) == completed.Task, "actual completion log observed");
Check(await completed.Task == !fixture.Unauthorized && completionLogs == 1, "original success/false result");
Check(saveFailureLogs == (fixture.Unauthorized ? 1 : 0), "expected 401 save error is observed exactly once");
Check(fixture.Sets == 1 && fixture.Auth == 1, "one immediate attempt and no reauthentication");
Check(fixture.Remote["k"] == "original", "ordinary event key not sent by immediate write");
if (fixture.Unauthorized)
{
Check(fixture.Remote["NewPlayerDataDic"] == "original-save", "401 did not commit snapshot");
}
else
{
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "original exact pre-event snapshot plus energy");
Check(expected["newData"].Split(',')[3] == initialEnergy.ToString(), "copied newData retains original value");
if (subscribers != null)
{
var originalSnapshot = new PlayerData(tables, null);
Module02OriginalLoadTests.LoadCore(originalSnapshot, JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]));
Check(originalSnapshot.Energy == initialEnergy && originalSnapshot.equipRodID == player.equipRodID && (ulong)originalSnapshot.gold == (ulong)player.gold, "original parser reads pre-event snapshot values without canonical rewrite");
}
var flush = await KVMgr.SyncKVAsync(owner);
Check(flush.TransportError == PlayerKVError.None && flush.Blocked == 0 && flush.Unknown == 0 && fixture.Remote["k"] == (subtype == 1 ? "event-" + cost : "original"), "subsequent ordinary event write");
if (subscriberWrites != null)
{
Check(subscriberWrites.All(pair => fixture.Remote.TryGetValue(pair.Key, out var value) && value == pair.Value), "ordinary tick persists exact actual task payloads");
Check(fixture.Remote["NewPlayerDataDic"] == expectedJson, "task ordinary save does not rewrite original immediate snapshot");
}
if (fixture.HoldSet)
{
var recovered = JsonConvert.DeserializeObject<Dictionary<string, string>>(fixture.Remote["NewPlayerDataDic"]);
Check(recovered["newData"].Split(',')[3] == (initialEnergy - cost + 1).ToString() && !recovered.ContainsKey("energy"), "later recovery saved separately");
}
}
}
Check(!Enabled && !Ready && handle == null, "no temporary gameplay activation");
}
catch (Exception ex)
{
errors.Add(ex);
}
finally
{
fixture.ReleaseSet.TrySetResult(true);
if (invoked && await Task.WhenAny(completed.Task, Task.Delay(5000)) != completed.Task)
{
errors.Add(new InvalidOperationException("Original async void did not drain: " + scenario));
validationCleanupFailure = fixture;
}
Clean(() => player?.StopRecoveryEnergy());
Clean(() => consumeSubscription?.Dispose());
Clean(() => playerSubscription?.Dispose());
Clean(() => subscribers?.Dispose());
Clean(() => achievements?.Dispose());
Clean(() => store?.Dispose());
try
{
await fixture.Close();
}
catch (Exception ex)
{
errors.Add(ex);
validationCleanupFailure = fixture;
}
Clean(() =>
{
if (shell != null)
{
UnityEngine.Object.DestroyImmediate(shell);
}
});
Clean(() => container?.Dispose());
FishingContextAccess.Use(oldContainer);
FishingContextAccess.Events = oldEvents;
Application.logMessageReceived -= Log;
if (unexpectedLogs.Count != 0)
{
errors.Add(new InvalidOperationException(string.Join("\n", unexpectedLogs)));
}
}
if (errors.Count != 0)
{
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "failure.json"), JsonConvert.SerializeObject(new { scenario, errors = errors.Select(x => x.ToString()), formalRequests = 0 }));
throw new AggregateException(errors);
}
passed.Add(scenario);
}
}
finally
{
FishingContextAccess.Use(oldContainer);
FishingContextAccess.Events = oldEvents;
LocalizationMgr.enumLanguage = oldLanguages;
try
{
textTable.SetValue(null, oldTextTable);
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
}
}
string result = JsonConvert.SerializeObject(new { success = true, groups = passed.Count, assertions = checks, passed, recoveryTiming = "manual-await-boundary", formalRequests = 0, synthetic = true, restored = true, folder });
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "result.json"), result);
return result;
}
private sealed class OriginalFishingTestContext : SynchronizationContext
{
private readonly Queue<(SendOrPostCallback Callback, object State)> posts = new Queue<(SendOrPostCallback, object)>();
private int started;
private int completed;
internal int Count => posts.Count;
internal int Started => started;
internal int Completed => completed;
public override void Post(SendOrPostCallback callback, object state)
{
posts.Enqueue((callback, state));
}
public override void OperationStarted()
{
started++;
}
public override void OperationCompleted()
{
completed++;
}
internal (SendOrPostCallback Callback, object State) Peek()
{
return posts.Peek();
}
internal (SendOrPostCallback Callback, object State) Take()
{
return posts.Dequeue();
}
}
// 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)
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);
GameObject saveShell=null;
var pfField=typeof(PlayFabMgr).GetField("_instance",System.Reflection.BindingFlags.Static|System.Reflection.BindingFlags.NonPublic);
var oldPF=pfField.GetValue(null);
FishingSubscribers subscribers=null;
int checks=0,groups=0; var passed=new List<string>(); string result=null;
void Check(bool yes,string reason) { checks++; if(!yes) throw new InvalidOperationException("Fishing validation: "+reason); }
}
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
{
throw new InvalidOperationException("Validation running.");
}
var saved = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction);
var folder = Path.Combine(Root, "fishing-fixed-runs", DateTime.UtcNow.ToString("yyyyMMddTHHmmss") + "_" + Guid.NewGuid().ToString("N"));
EquipmentFixture fixture = null;
Task pending = 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; }
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);saveShell=null;
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose(); container=null;
fixture=new EquipmentFixture(Path.Combine(folder,(++groups).ToString()));
var owner=await fixture.Open();
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);
if (fixture != null)
{
await fixture.Close();
}
if (pending != null)
{
try
{
await pending;
}
catch
{
}
pending = 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++);
Publish(owner);
passed.Add(name);
}
(FishingAction action,WriteBatch batch,PlayerKVEditResult primary) Batch(bool duplicates=false)
(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);
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)
async Task Reject(Task task, string label)
{
bool rejected=false; try { await task; } catch(InvalidOperationException) { rejected=true; }
Check(rejected && !Ready && fixture.Faults==1,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();
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");
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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");
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);
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
await Reject(ConfirmFishingAsync(b.action, b.batch, b.primary), "never substitute later ticket");
await Start("lost-response-after-commit");
b = Batch();
fixture.LoseResponse = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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))
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);
bool storage = false;
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");
Write("NewPlayerDataDic", "locked", true);
}
finally
catch (InvalidOperationException e)
{
try { tween?.Kill(false); UnityEngine.Object.DestroyImmediate(shell); }
finally { initialized.SetValue(null,wasInitialized); }
storage = e.Message.Contains("StorageError");
}
Check(storage && !Ready && fixture.Faults == 1 && fixture.Sets == 0, "storage failure cannot be accepted or retried");
}
await Start("completed-action-normal-disable-is-not-failure");
var completedShell=new GameObject("KV completed disable fixture");completedShell.SetActive(false);completedShell.hideFlags=HideFlags.HideAndDontSave;
await Start("observation-timeout-does-not-replay");
b = Batch();
fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(b.batch.Owner); // One simulated ordinary timer tick.
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
{
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");
BeginFishing(new object ());
}
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"})
catch (InvalidOperationException)
{
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);
saveShell=new GameObject("KV inactive consumption storage fixture");saveShell.SetActive(false);saveShell.hideFlags=HideFlags.HideAndDontSave;
var pf=saveShell.AddComponent<PlayFabMgr>();Check(pf.IsUnstartedForKVLoad,"consumption fixture never starts PF lifecycle");pfField.SetValue(null,pf);
var store=new PlayFabPlayerSaveStore(pf,CancellationToken.None);
container.RegisterInstance<IPlayerSaveStore>(store);
LocalizationMgr.enumLanguage=tables.TbLanguageConfig.DataList.Select(x=>x.ID).ToList();
textTable.SetValue(null,null);
player=new PlayerData(tables,null); var small=new SmallGameData(tables,null,null); var fish=new PlayerFishData(tables,null);
var achievements=new AchievementDataManager(tables); var box=new FishingBoxData(); var events=new FishingEventData(tables);
container.RegisterInstance(player); container.RegisterInstance(small); container.RegisterInstance(fish);
container.RegisterInstance(achievements); container.RegisterInstance(box); container.RegisterInstance(events);
player.playerSaveStore=store;fish.playerSaveStore=store;small.playerSaveStore=store;events.playerSaveStore=store;
container.RegisterInstance<ILocalNotificationService>(new FishingNotifications());
fish.ResetForKVLoad(); small.ResetForKVLoad(); achievements.ResetForKVLoad(); box.ResetForKVLoad();
achievements.Init();
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");
denied = true;
}
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 KVMgr.FlushKVAsync(action.Owner);
var recovered=JsonConvert.DeserializeObject<Dictionary<string,string>>(fixture.Remote["NewPlayerDataDic"]);
Check(recovered["newData"].Split(',')[3]==(initialEnergy-2).ToString(),"later recovery has separate flush, not original action confirmation");
}
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});
Check(denied && fixture.Sets == 0, "old permit cannot consume");
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); } }
pfField.SetValue(null,oldPF);if(saveShell!=null)UnityEngine.Object.DestroyImmediate(saveShell);
FishingContextAccess.Use(oldContainer); FishingContextAccess.Events=oldEvents; container?.Dispose();
LocalizationMgr.enumLanguage=oldLanguages; textTable.SetValue(null,oldTextTable);
(Enabled,Ready,Error,Permit,RunPath,handle,onFault,onClose,collecting,mainThread,failed,mainContext,fishingAction)=saved;
if (fixture != null)
{
await fixture.Close();
}
if (pending != null)
{
try
{
await pending;
}
catch (Exception e)
{
Debug.LogWarning("Observed fishing test failure: " + e.GetType().Name);
}
}
(Enabled, Ready, Error, Permit, RunPath, handle, onFault, collecting, mainThread, failed, mainContext, fishingAction) = saved;
}
catch
{
validationCleanupFailure = fixture;
throw;
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
}
catch { validationCleanupFailure=fixture; throw; }
finally { Interlocked.Exchange(ref validationRunning,0); }
}
File.WriteAllText(Path.Combine(folder,"result.json"),result); return result;
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;
private readonly object oldRed = Red.GetValue(null);
private LuckyTaskData magicTask;
private 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 store=container.Resolve<IPlayerSaveStore>();
container.RegisterInstance<IPlayerSaveStore>(store);
container.Resolve<PlayerData>().playerSaveStore=store;
@@ -322,10 +726,40 @@ namespace game.PlayerDataStorage
}
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);
var errors = new List<Exception>();
void Release(Action action)
{
try
{
action();
}
catch (Exception error)
{
errors.Add(error);
}
}
Release(() => Battle?.Dispose());
Release(() => Lucky?.Dispose());
Release(() => magicTask?.Dispose());
Release(() =>
{
if (Mini != null)
{
(typeof(MiniBattlePassDataModel).GetField("disposables", Fields).GetValue(Mini) as IDisposable)?.Dispose();
}
});
Release(() => Red.SetValue(null, oldRed));
Release(() =>
{
if (!ReferenceEquals(Red.GetValue(null), oldRed))
{
throw new InvalidOperationException("Subscriber fixture statics did not restore.");
}
});
if (errors.Count != 0)
{
throw new AggregateException(errors);
}
}
}
sealed class FishingContextAccess:GContext

View File

@@ -86,37 +86,17 @@ namespace game.PlayerDataStorage
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()
internal static void Attach(Action<string> fault)
{
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});
onFault = fault;
}
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)
@@ -160,12 +140,6 @@ namespace game.PlayerDataStorage
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;
@@ -220,9 +194,6 @@ namespace game.PlayerDataStorage
last.Values.Any(r=>!r.Remote || r.Edit.Ticket==null)) throw Stop("Fishing final writes require original remote tickets.");
using(var timeout=new CancellationTokenSource(TimeSpan.FromSeconds(30)))
{
var flush=await KVMgr.FlushKVAsync(batch.Owner,timeout.Token);
if(!FishingCurrent(action) || flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None)
throw Stop("Fishing flush: "+flush.TransportError);
foreach(var record in batch.Records)
{
var ticket=record.Edit.Ticket;
@@ -250,33 +221,6 @@ namespace game.PlayerDataStorage
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 KVMgr.FlushKVAsync(batch.Owner);
if(flush.Unknown>0 || flush.Blocked>0 || flush.TransportError!=PlayerKVError.None) throw Stop("Equipment flush: "+flush.TransportError);
foreach(var edit in batch.Edits)
{
if(edit.Ticket==null) continue; // localOnly is audited, never claimed as remotely confirmed.
var result=await KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket);
if(result.Status!=PlayerKVTicketStatus.Confirmed) throw Stop("Equipment ticket: "+result.Status);
}
if(!ReferenceEquals(Capture(),batch.Owner)) throw Stop("Equipment session changed.");
Audit("equipment-confirmed",new{tickets=batch.Edits.Count(e=>e.Ticket!=null)});
}
internal static async Task FlushObservedAsync()
{
if(!Ready) return;
try
{
var result=await KVMgr.FlushKVAsync(Capture());
if(result.Unknown>0 || result.Blocked>0 || result.TransportError!=PlayerKVError.None) throw Stop("Periodic flush: "+result.TransportError);
}
catch(Exception e) { Fault(e.Message); }
}
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)
{
@@ -299,127 +243,281 @@ namespace game.PlayerDataStorage
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)
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); }
}
if (Interlocked.CompareExchange(ref validationRunning, 1, 0) != 0)
{
throw new InvalidOperationException("Validation already running.");
}
var original = (Enabled, Ready, Error, Permit, RunPath, handle, onFault, 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;
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());
}
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.");
}
foreach (var edit in batch.Edits)
{
// localOnly is audited, never claimed as remotely confirmed.
if (edit.Ticket == null)
{
continue;
}
var result = await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket);
if (result.Status != PlayerKVTicketStatus.Confirmed)
{
throw Stop("Equipment ticket: " + result.Status);
}
}
if (!ReferenceEquals(Capture(), batch.Owner))
{
throw Stop("Equipment session changed.");
}
Audit("equipment-confirmed", new { tickets = batch.Edits.Count(e => e.Ticket != null) });
}
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);
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++);
Publish(owner);
groups++;
}
async Task Reject(Task task,string contains)
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 KVMgr.WaitForKVTicketAsync(batch.Owner,edit.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"each original ticket confirmed");
Check(fixture.Remote["k"]=="nested" && fixture.Remote["NewPlayerDataDic"]=="whole-save" && Ready && fixture.Faults==0,"complete batch committed");
await Start(); batch=BeginEquip();
var first=Write("k","first",true); Write("k","second",true);
primary=Write("NewPlayerDataDic","whole-save",true); batch.Dispose();
await Reject(ConfirmEquipAsync(batch,primary),"Superseded");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,first.Ticket)).Status==PlayerKVTicketStatus.Superseded,"original nested ticket never replaced");
await Start(); batch=BeginEquip();
Write("k","nested",true); primary=Write("NewPlayerDataDic","watermark",true); batch.Dispose();
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 KVMgr.FlushKVAsync(batch.Owner);
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner,late.Ticket)).Status==PlayerKVTicketStatus.Confirmed,"later write needs its own flush");
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","unauthorized",true); batch.Dispose();
fixture.Unauthorized=true;
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 KVMgr.WaitForKVTicketAsync(batch.Owner,primary.Ticket)).Status==PlayerKVTicketStatus.Unknown,"unknown original ticket preserved");
await Start(); batch=BeginEquip(); primary=Write("NewPlayerDataDic","closed",true); batch.Dispose();
await fixture.Close();
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();
bool rejected = false;
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"))));
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");
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await ConfirmEquipAsync(batch, primary);
foreach (var edit in batch.Edits)
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, edit.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "each original ticket confirmed");
Check(fixture.Remote["k"] == "nested" && fixture.Remote["NewPlayerDataDic"] == "whole-save" && Ready && fixture.Faults == 0, "complete batch committed");
await Start();
batch = BeginEquip();
var first = Write("k", "first", true);
Write("k", "second", true);
primary = Write("NewPlayerDataDic", "whole-save", true);
batch.Dispose();
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Superseded");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, first.Ticket)).Status == PlayerKVTicketStatus.Superseded, "original nested ticket never replaced");
await Start();
batch = BeginEquip();
Write("k", "nested", true);
primary = Write("NewPlayerDataDic", "watermark", true);
batch.Dispose();
fixture.HoldSet = true;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
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 KVMgr.SyncKVAsync(batch.Owner);
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, late.Ticket)).Status == PlayerKVTicketStatus.Confirmed, "later write needs its own flush");
await Start();
batch = BeginEquip();
primary = Write("NewPlayerDataDic", "unauthorized", true);
batch.Dispose();
fixture.Unauthorized = true;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
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;
_ = KVMgr.SyncKVAsync(batch.Owner); // One simulated ordinary timer tick, outside confirmation.
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
Check(fixture.Remote["NewPlayerDataDic"] == "lost-response" && fixture.Sets == 1, "unknown remote commit is not replayed");
Check((await KVMgr.WaitForKVTicketAsync(batch.Owner, primary.Ticket)).Status == PlayerKVTicketStatus.Unknown, "unknown original ticket preserved");
await Start();
batch = BeginEquip();
primary = Write("NewPlayerDataDic", "closed", true);
batch.Dispose();
await fixture.Close();
await Reject(ConfirmEquipAsync(batch, primary), "Equipment ticket");
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");
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.LoadData("{\"EventId\":11,\"AmmoCount\":7}");
var shootingData = JObject.FromObject(Module02OriginalLoadTests.Field<object>(shooting, "_data"));
Check((int)shootingData["EventId"] == 11 && (int)shootingData["AmmoCount"] == 7, "M24 registered instance uses original load method");
}
finally { EquipmentContextAccess.Use(originalContainer); container.Dispose(); }
Check(ReferenceEquals(GContext.container,originalContainer),"M24 global container restored");
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});
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;
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, collecting, mainThread, failed, mainContext) = original;
}
catch
{
validationCleanupFailure = fixture;
throw;
}
finally
{
Interlocked.Exchange(ref validationRunning, 0);
}
catch { validationCleanupFailure=fixture; throw; }
finally { Interlocked.Exchange(ref validationRunning,0); }
}
File.WriteAllText(Path.Combine(folder,"result.json"),result);
File.WriteAllText(Path.Combine(folder, "result.json"), result);
return result;
}
@@ -430,37 +528,29 @@ namespace game.PlayerDataStorage
}
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
if (Enabled || validationRunning != 0 || UnityEditor.EditorApplication.isPlayingOrWillChangePlaymode || GContext.Ready || UnityEngine.Resources.FindObjectsOfTypeAll<GContext>().Length != 0)
{
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});
throw new InvalidOperationException("Idle editor required for source inspection.");
}
finally { EquipmentContextAccess.Use(originalContainer); isolated.Dispose(); }
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 actual = Module02OriginalLoadTests.InspectRaw(raw);
return JsonConvert.SerializeObject(new { sourceOnly = true, usesLocalUtc = true, customId = id, path, sha256 = PlayerKVLoopPermit.Hash(bytes), keys = raw.Count, originalLoad = JObject.Parse(actual), gameplayPublished = false, formalRequests = 0 });
}
sealed class EquipmentFixture
{
@@ -514,108 +604,35 @@ namespace game.PlayerDataStorage
internal async Task Close()
{
ReleaseSet.TrySetResult(true);
await bridge.CloseAsync();
session.Dispose();
Exception closeError = null;
try
{
await bridge.CloseAsync();
}
catch (Exception error)
{
closeError = error;
throw;
}
finally
{
try
{
session.Dispose();
}
catch (Exception disposeError)
{
if (closeError != null)
{
throw new AggregateException(closeError, disposeError);
}
throw;
}
}
}
}
}
#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

View File

@@ -1,123 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using asap.core;
using game.PlayerDataStorage;
using UnityEngine;
namespace game
{
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);
}
void IKVUserLoadReceiver.ResetForKVLoad() => ResetForKVLoad();
void IKVUserLoadReceiver.ReplaceFromKVDecoded(PlayerKVLoginState state,long epoch,IReadOnlyDictionary<string,string> raw)
=> ReplaceFromKVDecoded(state,epoch,raw);
void IKVUserLoadReceiver.DiscardKVIntents() => KVIntents?.Discard();
bool IKVUserLifecycle.KVHandoffReady => false;
PlayerKVGameLoadError IKVUserLifecycle.KVLastError => KVLastError;
Task IKVUserLifecycle.CloseKVForValidationAsync() => CloseKVForValidationAsync();
Task<PlayerKVShutdownStatus> IKVUserLifecycle.ObserveKVQuitAsync() => ObserveKVQuitAsync();
internal virtual void ReplaceKVLinked(PlayerKVLoginState state) { }
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a676add461b14c90b0f8a6e420884fd6

View File

@@ -98,7 +98,7 @@ namespace game
void UpdateProfileLv();
}
public partial class PlayFabUserService : IUserService, PlayerDataStorage.IKVUserLoadReceiver, PlayerDataStorage.IKVUserLifecycle
public class PlayFabUserService : IUserService
{
[Inject]
public IConfig config { get; set; }
@@ -106,7 +106,7 @@ namespace game
[Inject]
public ICustomServerMgr customServerMgr { get; set; }
public bool IsLogin => !kvControlledInstance && PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized;
public bool IsLogin => PlayFabClientAPI.IsClientLoggedIn() && IsAuthorized;
public bool IsAuthorized => InternalId != 0 && apiSession != null && apiSession.IsAuthenticated;
@@ -145,18 +145,11 @@ namespace game
return "Angler#" + (string.IsNullOrEmpty(id) ? string.Empty : id.Substring(0, Math.Min(5, id.Length)));
}
public PlayFabUserService() : this(null) { }
internal PlayFabUserService(PlayerDataStorage.PlayerKVControlledLoadCapability capability)
public PlayFabUserService()
{
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();
});
@@ -168,6 +161,16 @@ namespace game
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());
internal string CaptureSaveBackend()
{
return config == null ? "playfab" : config.Get<string>("SaveBackend", "playfab");
}
internal bool RejectSaveBackend(string backend)
{
return backend != "playfab";
}
protected async Task<ELoginStep> LoginWithBackend(Action<int> progress,string backend)
{
var generation = logoutGeneration;
@@ -189,9 +192,6 @@ namespace game
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);
@@ -351,11 +351,7 @@ 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()
{
@@ -518,9 +514,6 @@ 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;
@@ -560,9 +553,6 @@ namespace game
return false;
}
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
{
@@ -672,17 +662,6 @@ 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++;

View File

@@ -199,20 +199,8 @@ namespace game
InitManager();
if (kvUser != null)
{
if (!GContext.container.Resolve<TimeManager.TimeRefreshManager>().InitialRefreshSucceeded)
throw new System.InvalidOperationException("KV initial daily refresh failed; previous login must be retained.");
// Daily refresh consumes the previous login first. Save its actual player aggregate
// and pending module writes before advancing the persisted login timestamp.
GContext.container.Resolve<PlayerData>().SavePlayerData();
var initialization = await store.FlushAsync(loginLifetime);
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
if (!kvUser.IsAuthorized || !store.IsCurrent || !initialization.Confirmed)
throw new System.InvalidOperationException("KV initialization saves were not confirmed.");
var loginSaved = await store.WriteAndConfirmAsync("LastLoginTimeUtc",
kvUser.LoginTime.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture), loginLifetime);
if (stopping || this == null || loginLifetime.IsCancellationRequested) return;
if (!kvUser.IsAuthorized || !store.IsCurrent || !loginSaved.Confirmed)
throw new System.InvalidOperationException("KV login timestamp was not confirmed.");
var loginTime = kvUser.LoginTime.ToUniversalTime().ToString("O", System.Globalization.CultureInfo.InvariantCulture);
_ = ObserveLastLoginSaveAsync(store, loginTime);
}
GContext.container.RegisterInstance<IStageData>(stageData);
GContext.container.Register<SurveyService>().AsSingleton();
@@ -271,6 +259,18 @@ namespace game
_autoRestart.Init();
}
private static async Task ObserveLastLoginSaveAsync(PlayerDataStorage.IPlayerSaveStore store, string loginTime)
{
try
{
await store.SyncDataImmidiate("LastLoginTimeUtc", loginTime);
}
catch (System.Exception ex)
{
Debug.LogError("[FishingStage] LastLogin save failed: " + ex);
}
}
void InitEvent()
{
var rtService = GContext.container.Resolve<IRTService>();
@@ -282,26 +282,14 @@ namespace game
MainThreadDispatcher.OnApplicationPauseAsObservable()
.Subscribe(paused =>
{
if (paused) _ = FlushPausedSaveAsync();
else _ = OnApplicationResumedAsync();
if (!paused)
{
_ = OnApplicationResumedAsync();
}
})
.AddTo(disposable);
}
private async Task FlushPausedSaveAsync()
{
var user = UserService;
var store = sessionSaveStore;
if (stopping || user is not KVUserService || !user.IsAuthorized || store == null || !store.IsCurrent) return;
try
{
var result = await store.FlushAsync(user.LoginLifetime);
if (!stopping && store.IsCurrent && !result.Confirmed)
Debug.LogWarning("KV pause save: " + result.Status);
}
catch (System.Exception ex) { Debug.LogError("KV pause save failed: " + ex.GetType().Name); }
}
private async Task OnApplicationResumedAsync()
{
var userService = UserService;

View File

@@ -19,34 +19,39 @@ namespace game
{
#if AGG
GContext.container.Register<IIAPService, AggIAPService>().AsSingleton();
GContext.container.Register<IUserService, AggUserService>().AsSingleton();
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.PlayFabPlayerSaveStore>().AsSingleton();
#else
GContext.container.Register<IIAPService, DevIAPService>().AsSingleton();
#endif
if (SubBoostrap.RuntimeSelection?.IsKV == true)
{
if (PlayFabMgr.HasLiveInstanceForKVLoad)
{
throw new System.InvalidOperationException("KV startup found an active legacy save manager.");
GContext.container.Register<IUserService, KVUserService>().AsSingleton();
}
#if AGG
GContext.container.Register<IUserService, AggKVUserService>().AsSingleton();
#else
GContext.container.Register<IUserService, DevKVUserService>().AsSingleton();
#endif
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.KVPlayerSaveStore>().AsSingleton();
}
else
{
#if AGG
GContext.container.Register<IUserService, AggUserService>().AsSingleton();
#else
GContext.container.Register<IUserService, PlayFabUserService>().AsSingleton();
#endif
GContext.container.Register<PlayerDataStorage.IPlayerSaveStore, PlayerDataStorage.PlayFabPlayerSaveStore>().AsSingleton();
}
GContext.container.Register<IIAPService, DevIAPService>().AsSingleton();
#endif
GContext.container.Register<PlayerFishData>().AsSingleton();
GContext.container.Register<PlayerItemData>().AsSingleton();
GContext.container.Register<CampDataMM>().AsSingleton();
GContext.container.Register<FishingEventData>().AsSingleton();
#if UNITY_EDITOR && !SERVICE_HOST
if(PlayerDataStorage.PlayerKVGameplayLoop.Enabled || SubBoostrap.RuntimeSelection?.IsKV==true)
if (SubBoostrap.RuntimeSelection?.IsKV == true)
{
GContext.container.Register<EventShootingRangeData>().AsSingleton();
#else
if(SubBoostrap.RuntimeSelection?.IsKV==true)
GContext.container.Register<EventShootingRangeData>().AsSingleton();
#endif
}
GContext.container.Register<SigninData>().AsSingleton();
//GContext.container.Register<TaskData>().AsSingleton();
GContext.container.Register<ClubService>().AsSingleton();
@@ -107,9 +112,6 @@ namespace game
startActId = "AggLoginAct";
#else
startActId = "PFLoginAct";
#endif
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) startActId="PFLoginAct";
#endif
if (!LoadingScreen.isShowing())
{
@@ -123,8 +125,6 @@ namespace game
protected override async Task RequestNextAct(string actId)
{
if(SubBoostrap.RuntimeSelection?.IsKV==true)
return;
await GContext.container.Resolve<IStageService>().SwitchStageAsync("FishingStage");
}
}

View File

@@ -32,6 +32,7 @@ namespace game
private string[] loadingInfos;
private CancellationTokenSource loginLifetime;
private CancellationToken loginToken;
private Task loginWork;
public override async Task<bool> StartAsync()
{
@@ -60,10 +61,22 @@ namespace game
//CancellationTokenSource source;
private async void DoLogin()
{
try { await LoginAsync(); }
catch (OperationCanceledException) when (loginToken.IsCancellationRequested) { }
catch (Exception ex) { Debug.LogException(ex); }
finally { StopLoadingInfo(); }
try
{
loginWork = LoginAsync();
await loginWork;
}
catch (OperationCanceledException) when (loginToken.IsCancellationRequested)
{
}
catch (Exception ex)
{
Debug.LogException(ex);
}
finally
{
StopLoadingInfo();
}
}
private async Task LoginAsync()
@@ -71,9 +84,6 @@ namespace game
loginToken.ThrowIfCancellationRequested();
UpdateLoadingInfo();
var configCustomId = config.Get<string>("CustomID", string.Empty);
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) configCustomId=game.PlayerDataStorage.PlayerKVGameplayLoop.Permit.PlayerId;
#endif
if (configCustomId == @"{{input}}")
{
LoadingScreen.HideProgressBar();
@@ -83,7 +93,7 @@ namespace game
LoadingScreen.ShowProgressBar();
UpdateLoadingInfo();
}
else
else if (SubBoostrap.RuntimeSelection?.IsKV == true || string.IsNullOrEmpty(configCustomId))
{
userService.CustomId = configCustomId;
}
@@ -91,37 +101,16 @@ namespace game
//source = new CancellationTokenSource();
//LoadingAutoInc(source.Token, 1f);
var formalKV=SubBoostrap.RuntimeSelection?.IsKV==true;
rtService.WaitForAPISvrLogin();
var step = await userService.Login(OnLoginStep);
loginToken.ThrowIfCancellationRequested();
if(formalKV)
{
StopLoadingInfo();
LoadingScreen.HideProgressBar();
if(step==ELoginStep.Done && kvLifecycle?.KVHandoffReady==true)
LoadingScreen.SetLoadingInfo("KV account loaded. Gameplay is not enabled yet.");
else
{
var error=kvLifecycle?.KVLastError.ToString() ?? "Input";
dialog.Config(new SimpleDialog.Context
{
message="KV login unavailable",
message2="Login stopped ("+error+"). Sign out or restart before trying again.",
okAction=()=>userService.Logout(), closeAction=()=>userService.Logout()
});
}
return;
}
if (step != ELoginStep.Done)
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) throw game.PlayerDataStorage.PlayerKVGameplayLoop.Stop("Login failed; restart the Unity process before another attempt.");
#endif
//source.Cancel();
//source.Dispose();
await ReytryDialog(userService.Login, LocalizationMgr.GetText("subtitle_loginfailed")
, LocalizationMgr.GetText("msg_loginfailed"));
step = ELoginStep.Done;
}
loginToken.ThrowIfCancellationRequested();
@@ -310,7 +299,11 @@ namespace game
loginLifetime?.Cancel();
loginLifetime?.Dispose();
loginLifetime = null;
if(SubBoostrap.RuntimeSelection?.IsKV==true) userService.Logout();
if (SubBoostrap.RuntimeSelection?.IsKV == true && loginWork != null &&
(loginWork.Status != TaskStatus.RanToCompletion || kvLifecycle?.KVHandoffReady != true))
{
userService.Logout();
}
}

View File

@@ -34,10 +34,25 @@ namespace game
public override async Task<bool> StartAsync()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) _=game.PlayerDataStorage.PlayerKVGameplayLoop.CloseAsync();
#endif
if(userService is PlayerDataStorage.IKVUserLifecycle closingKVUser) _=closingKVUser.CloseKVForValidationAsync();
if (userService is PlayerDataStorage.IKVUserLifecycle closingKVUser)
{
try
{
var original = (userService as KVUserService)?.SaveStore as PlayerDataStorage.KVPlayerSaveStore;
if (original != null && original.IsCurrent)
{
_ = original.SyncDataAsync();
}
}
catch (System.Exception ex)
{
Debug.LogError("KV quit save failed: " + ex.GetType().Name);
}
finally
{
_ = closingKVUser.CloseKVForValidationAsync();
}
}
// Invalidate identity before any await can allow old login/HTTP callbacks to resume.
if (QuitStage.Logout)
{
@@ -54,10 +69,6 @@ namespace game
catch (System.Exception ex) { Debug.LogException(ex); }
// Observation is bounded; the process registry still owns any unfinished close.
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) await PlayerDataStorage.PlayerKVShutdownRegistry.Process.ObserveAsync(5000);
else
#endif
if(userService is PlayerDataStorage.IKVUserLifecycle kvUser) await kvUser.ObserveKVQuitAsync();
adsService.Release();
@@ -87,16 +98,6 @@ namespace game
}
}
base.OnStarted();
#if UNITY_EDITOR && !SERVICE_HOST
if (game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
var status = PlayerDataStorage.PlayerKVShutdownRegistry.Process.Status;
Debug.Log("KV loop ended; restart Unity before another run. Shutdown: " + status);
if (status == PlayerDataStorage.PlayerKVShutdownStatus.Ready)
UnityEditor.EditorApplication.isPlaying = false;
return; // Keep the process barrier; never return this run to PreBoot.
}
#endif
Invoke("Unload", 2f);
}
}

View File

@@ -1,4 +1,4 @@
using AppsFlyerSDK;
using asap.core;
using asap.core.common;
@@ -26,9 +26,12 @@ public class SubBoostrap
{
internal static game.PlayerDataStorage.PlayerKVRuntimeSelection RuntimeSelection { get; private set; }
public static async System.Threading.Tasks.Task RegisterServices(GameObject rootCtxGo, IConfig config)
public static async Task RegisterServices(GameObject rootCtxGo, IConfig config)
{
if(config==null) throw new ArgumentNullException(nameof(config));
if (config == null)
{
throw new ArgumentNullException(nameof(config));
}
#if AGG
const bool aggBuild=true;
#else
@@ -97,10 +100,14 @@ public class SubBoostrap
#else
GameCore.GameEventMgr.Init();
#endif
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.BeforeBootstrap();
#endif
if(!selection.IsKV) rootCtxGo.AddComponent<PlayFabMgr>();
if (!selection.IsKV)
{
rootCtxGo.AddComponent<PlayFabMgr>();
}
else
{
rootCtxGo.AddComponent<KVMgr>();
}
// TODO Replace DataCenter with seprated data class
// Create();
// -----------------------------------------

View File

@@ -20,7 +20,7 @@ namespace TimeManager
_sessionStartTime = ZZTimeHelper.UtcNow().UtcNowOffset();
_lastCheckedHour = _sessionStartTime.Hour;
InitialRefreshSucceeded = CheckLastLogin();
CheckLastLogin();
_tickService.MinuteTick
.Subscribe(_ =>
{
@@ -32,7 +32,6 @@ namespace TimeManager
private readonly ITimeTickService _tickService;
private readonly IUserService _userService;
public bool InitialRefreshSucceeded { get; }
private int _lastCheckedDayOfYear = -1;
private int _lastCheckedHour = -1;
@@ -41,11 +40,14 @@ namespace TimeManager
#region Check登录时间
private bool CheckLastLogin()
private void CheckLastLogin()
{
if (IsLastLoginToday()) return true;
if (IsLastLoginToday())
{
return;
}
Debug.Log("上次登录不是今天");
return OnCrossDayRefresh();
OnCrossDayRefresh();
}
private void CheckSessionCrossDay()
{
@@ -123,18 +125,16 @@ namespace TimeManager
/// <summary>
/// 跨天刷新逻辑
/// </summary>
private bool OnCrossDayRefresh()
private void OnCrossDayRefresh()
{
try
{
// 1. 发送活动跨天刷新事件
GContext.Publish(new ActivityCrossDayRefreshEvent());
return true;
}
catch (Exception e)
{
Debug.LogError($"[TimeRefreshManager] Error during cross day refresh: {e.Message}\n{e.StackTrace}");
return false;
}
}

View File

@@ -581,8 +581,11 @@ public class DeBugPanel : MonoBehaviour
private void ChangeMMT()
{
var userService = GContext.container.Resolve<game.IUserService>() as game.PlayFabUserService;
if (userService == null) return;
var userService = GContext.container.Resolve<game.IUserService>();
if (userService is not game.PlayFabUserService && userService is not game.KVUserService)
{
return;
}
string mmt = inputField_mmt.text;
List<string> mmts = mmt.ToLower().Select(c => c.ToString()).ToList();
var mmDatas = GContext.container.Resolve<Tables>().TbMMTInit.DataList;
@@ -594,7 +597,14 @@ public class DeBugPanel : MonoBehaviour
value |= 1 << (mmDatas[i].ID - 1001);
}
}
userService.GMMMT(value);
if (userService is game.KVUserService kvUserService)
{
kvUserService.GMMMT(value);
}
else
{
((game.PlayFabUserService)userService).GMMMT(value);
}
}
private string _cfg_ver;

View File

@@ -17,24 +17,6 @@ using UnityEngine.UI;
public class FishingRewardPanel : BasePanel
{
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.FishingRewardAction kvReward;
System.Func<System.Threading.Tasks.Task> kvPresentation;
bool KVRewardViewCurrent() => !game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled ||
(this!=null && isActiveAndEnabled && game.PlayerDataStorage.PlayerKVGameplayLoop.RewardPanelCurrent(kvReward,this));
async System.Threading.Tasks.Task ConfirmKVRewardAsync(game.PlayerDataStorage.PlayerKVEditResult primary)
{
try
{
await game.PlayerDataStorage.PlayerKVGameplayLoop.ConfirmRewardAsync(kvReward,primary);
if(!KVRewardViewCurrent()) return;
game.PlayerDataStorage.PlayerKVGameplayLoop.PresentReward(kvReward,()=>{canvasGroup.alpha=1;canvasGroup.blocksRaycasts=true;canvasGroup.interactable=true;});
if(kvPresentation!=null) await kvPresentation();
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
}
void OnDisable() => game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel disabled");
#endif
Tables _tables;
private CanvasGroup canvasGroup;
private CanvasGroup root_success;
@@ -163,9 +145,6 @@ public class FishingRewardPanel : BasePanel
dragImage = transform.Find("root_success/UIDrag").GetComponent<UIDrag>();
canvasGroup = GetComponent<CanvasGroup>();
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) { canvasGroup.alpha=0; canvasGroup.blocksRaycasts=false; canvasGroup.interactable=false; }
#endif
root_success = transform.Find("root_success").GetComponent<CanvasGroup>();
@@ -263,9 +242,6 @@ public class FishingRewardPanel : BasePanel
}
if (GContext.container.Resolve<FishingEventData>().IsOpenDrop)
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
CheckResource();
}
}
@@ -284,12 +260,8 @@ public class FishingRewardPanel : BasePanel
fishingData = GContext.container.Resolve<FishingData>();
Magnification = playerData.GetMagnification();
playButton.onClick.AddListener(OnClickInHome);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
{
go_fishPhoto.OnBackClick = OnBackClick;
playButton.onClick.AddListener(OnClickInHome);
targetBtn.onClick.AddListener(OnTargetClosePanel);
oneVOneBattleBtn.onClick.AddListener(OnTargetClosePanel);
btn_green.onClick.AddListener(OnSucceed);
@@ -319,7 +291,6 @@ public class FishingRewardPanel : BasePanel
dragImage.OnDragCall += OnDragFish;
dragImage.OnEndDragCall += StartDoRotate;
}
OnShow();
}
void StartDoRotate()
@@ -338,31 +309,6 @@ public class FishingRewardPanel : BasePanel
}
void OnShow()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
try
{
var bound=game.PlayerDataStorage.PlayerKVGameplayLoop.BindRewardPanel(this);
if(bound==null) return;
kvReward=bound;
game.PlayerDataStorage.PlayerKVEditResult primary;
using(game.PlayerDataStorage.PlayerKVGameplayLoop.BeginRewardSegment("reward"))
{
OnShowCore();
playerFishData.SaveCaughtFishForKVLoop(fishID);
primary=playerData.SaveFishingRewardForKVLoop();
}
_=ConfirmKVRewardAsync(primary);
}
catch(System.Exception e) { game.PlayerDataStorage.PlayerKVGameplayLoop.Fault(e.Message); }
return;
}
#endif
OnShowCore();
}
void OnShowCore()
{
var normalRank = _tables.TbGlobalConfig.Rank;
var spRank = _tables.TbGlobalConfig.RankForSpFish;
@@ -425,23 +371,13 @@ public class FishingRewardPanel : BasePanel
if (fishingData.fishingResult == FishingResult.Success)
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
AudioRewardPanel();
root_fail.SetActive(false);
root_success.gameObject.SetActive(fishingData.IsGeneralFish());
root_target.SetActive(fishingData.IsKeepsakeFish() || fishingData.IsPackItemFish());
root_1v1battle.SetActive(fishingData.fishItem.IsSpinachItem());
float pointBonus = playerFishData.GetRodAscendStats();
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) kvPresentation=ApplySuccess(pointBonus);
else
#endif
Success(pointBonus);
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
StartCoroutine(Blur());
#if AGG
@@ -482,9 +418,6 @@ public class FishingRewardPanel : BasePanel
}
#endif
GContext.Publish(new OnEventTriggerGuide(GetType().Name));
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) game.PlayerDataStorage.PlayerKVGameplayLoop.RecordRewardGuideNotification();
#endif
fishingData.IsRetry = false;
}
else
@@ -548,19 +481,11 @@ public class FishingRewardPanel : BasePanel
//Pulling 结束后
float time = fishingData.fishingBehaviorConf.blurDelay;
yield return new WaitForSeconds(time);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) yield break;
#endif
EnableFishingBlur(true);
}
//钓鱼成功
async void Success(float pointBonus)
{
var present=ApplySuccess(pointBonus);
if(present!=null) await present();
}
System.Func<System.Threading.Tasks.Task> ApplySuccess(float pointBonus)
{
go_info.gameObject.SetActive(false);
GContext.container.Resolve<PlayerData>().SaveFishingFrequency();
@@ -572,9 +497,6 @@ public class FishingRewardPanel : BasePanel
GContext.container.Resolve<PlayerItemData>().AddItemCount(curCollectingID, addCollecting, null);
MaterialChange();
}
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
PlayAudioRewardFish();
GContext.container.Resolve<BuffDataCenter>().EndCollectionTargetWeight(fishingData.fishItem.ID);
fishingEventData.AddProgress(fishingData.fishItem.Quality, Magnification);
@@ -644,9 +566,6 @@ public class FishingRewardPanel : BasePanel
}
GContext.Publish(new TargetAddData(dropPackageList.DropIDList[1], 0, add));
var isSpecialFish = fishingData.curFishData.GeneralType == FishGeneralType.SpMapFish || fishingData.curFishData.GeneralType == FishGeneralType.EventFish;
#if UNITY_EDITOR && !SERVICE_HOST
if(!game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
#endif
SetTextTitleAsync(proficiencyExtra, isSpecialFish);
SetFishWeight(fishingData.fishWeight);
MMTService mmService = GContext.container.Resolve<MMTService>();
@@ -661,28 +580,6 @@ public class FishingRewardPanel : BasePanel
}
}
bonusReward.Show(Magnification);
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
return async ()=>
{
if(!KVRewardViewCurrent()) return;
AudioRewardPanel(); PlayAudioRewardFish(); SetTextTitleAsync(proficiencyExtra,isSpecialFish);
_ = StartCoroutine(Blur());
await PresentGeneralRewardAsync(glodBuff);
};
#endif
return ()=>PresentGeneralRewardAsync(glodBuff);
}
else if (fishingData.fishItem.IsSpinachItem())
{
//todo 1v1battle
root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8");
}
return null;
}
async System.Threading.Tasks.Task PresentGeneralRewardAsync(float glodBuff)
{
_ = StartCoroutine(AddCash());
//Show Fish Info UI
go_info.gameObject.SetActive(true);
@@ -696,11 +593,7 @@ public class FishingRewardPanel : BasePanel
anim_panel.Play("info_show_2");
await new WaitForSeconds(waitTime1 + duration1 + waitTime2);
if (!isEnable
#if UNITY_EDITOR && !SERVICE_HOST
|| !KVRewardViewCurrent()
#endif
)
if (!isEnable)
{
return;
}
@@ -712,9 +605,6 @@ public class FishingRewardPanel : BasePanel
var addCash = add - newCash;
DOTween.To(() => progress, x => progress = x, 1f, duration2).OnUpdate(() =>
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_gold_num.text = ConvertTools.GetNumberString2((int)(newCash + addCash * progress));
text_num_buff.text = text_gold_num.text;
}).SetId("AddCash");
@@ -730,14 +620,17 @@ public class FishingRewardPanel : BasePanel
}
VibrationData();
fishingRewardSocial.ShowSocial(isSocial);
}
else if (fishingData.fishItem.IsSpinachItem())
{
//todo 1v1battle
root_1v1battle_text_info.text=LocalizationMgr.GetText("UI_GiftBargainPopupPanel_8");
}
}
async void VibrationData()
{
await Awaiters.Seconds(2.5f);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
if (curRateIndex >= _tables.TbGlobalConfig.RankVibration.Count)
curRateIndex = _tables.TbGlobalConfig.RankVibration.Count - 1;
int hapticType = _tables.TbGlobalConfig.RankVibration[curRateIndex];
@@ -824,14 +717,8 @@ public class FishingRewardPanel : BasePanel
var progress = 0f;
int cash = add / Magnification;
yield return Awaiters.Seconds(waitTime1);
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) yield break;
#endif
DOTween.To(() => progress, x => progress = x, 1, duration1).OnUpdate(() =>
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_weight_num.text = LocalizationMgr.GetFormatTextValue("UI_FishingRewardPanel_101014",
ConvertTools.GetNumberString2((int)(_fishScore * progress)));
text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight * progress, true);
@@ -842,9 +729,6 @@ public class FishingRewardPanel : BasePanel
.SetId("AddCash")
.OnComplete(() =>
{
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
text_pound.text = LocalizationMgr.GetWeight(fishingData.fishWeight);
}
);
@@ -869,10 +753,7 @@ public class FishingRewardPanel : BasePanel
curRate = etor.Current.Key;
}
while (etor.MoveNext());
var rateObject=await Addressables.InstantiateAsync(curRate, icon_rate).Task;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent() && rateObject!=null) Addressables.ReleaseInstance(rateObject);
#endif
await Addressables.InstantiateAsync(curRate, icon_rate).Task;
}
bool _newRecord;
@@ -1176,10 +1057,6 @@ public class FishingRewardPanel : BasePanel
protected override void OnDestroy()
{
#if UNITY_EDITOR && !SERVICE_HOST
game.PlayerDataStorage.PlayerKVGameplayLoop.CancelReward(this,"reward panel destroyed");
#endif
//ConvertTools.SetFSBlur(false);
EnableFishingBlur(false);
GContext.Publish(new PlayNewItemFxEvent() { type = 2 });
@@ -1276,9 +1153,6 @@ public class FishingRewardPanel : BasePanel
if (!string.IsNullOrEmpty(fishingData.AudioRewardFish))
{
NewItemAudio = await Addressables.LoadAssetAsync<AudioClip>(fishingData.AudioRewardFish).Task;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) { if(NewItemAudio!=null) Addressables.Release(NewItemAudio); NewItemAudio=null; return; }
#endif
PlayNewItemAudio();
fishingData.AudioRewardFish = "";
}
@@ -1340,9 +1214,6 @@ public class FishingRewardPanel : BasePanel
await GContext.container.Resolve<SoundMgr>().PlayFishingSound(fishingData.curFishData.AudioRewardPanel);
}
await Awaiters.NextFrame;
#if UNITY_EDITOR && !SERVICE_HOST
if(!KVRewardViewCurrent()) return;
#endif
if (this != null && !string.IsNullOrEmpty(fishingData.curFishData.AudioRewardPanelLoop))
{
GContext.Publish(new EventBGMSound(fishingData.curFishData.AudioRewardPanelLoop));
@@ -1365,10 +1236,6 @@ public class FishingRewardPanel : BasePanel
string fishName = "0";
private void OnGUI()
{
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled) return;
#endif
int fontSize = 40;
GUI.skin.textField.fontSize = fontSize;
GUI.skin.button.fontSize = fontSize;

View File

@@ -12,7 +12,7 @@ namespace Assets.Scripts.UI.JigsawPuzzle
{
public class JigsawPuzzleManager:IDisposable
{
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>();
readonly game.PlayerDataStorage.IPlayerSaveStore saveStore = GContext.container.Resolve<game.PlayerDataStorage.IPlayerSaveStore>();
public const string PriceImgName = "separate_icon_eventjigsawpuzzle_piece_";

View File

@@ -1,4 +1,4 @@
using System.Collections.Generic;
using System.Collections.Generic;
using asap.core;
using cfg;
using GameCore;
@@ -12,32 +12,13 @@ namespace game
{
[asap.core.Inject] public game.PlayerDataStorage.IPlayerSaveStore SaveStore { get; set; }
private readonly EventPinataPackData _kvTarget;
private EventPinataPackDataManager(EventPinataPackData target) { _kvTarget=target; }
internal static EventPinataPackDataManager CreateForKVLoad()
{
var target=GContext.container.Resolve<EventPinataPackData>();
if(target==null) throw new System.InvalidOperationException("KV Pinata target is not registered.");
return new EventPinataPackDataManager(target);
}
private EventPinataPackData RequireKVTarget()
{
var target=GContext.container.Resolve<EventPinataPackData>();
if(target==null || (_kvTarget!=null && !ReferenceEquals(target,_kvTarget)))
throw new System.InvalidOperationException("KV Pinata target changed.");
return target;
}
internal static PlayerDataStorage.PlayerKVPinataState DecodeForKVLoad(IReadOnlyDictionary<string,string> raw,
PlayerDataStorage.PlayerKVSchemaDiagnostics errors) => PlayerDataStorage.PlayerKVSaveSchema.DecodePinata(raw,errors);
internal void ResetForKVLoad()
{
var target=RequireKVTarget(); target.EventID=0; target.PinataMainID=0; target.PurchasedCount=0;
}
internal void ReplaceFromKVDecoded(PlayerDataStorage.PlayerKVPinataState state)
{
if(state==null) throw new System.ArgumentNullException(nameof(state));
var target=RequireKVTarget(); target.EventID=state.EventID; target.PinataMainID=state.PinataMainID; target.PurchasedCount=state.PurchasedCount;
}
#if UNITY_EDITOR
private bool m_IsCheckCondition = true;
#else

View File

@@ -228,12 +228,7 @@ public partial class FishingRodPanel : BasePanel
fx_rodpanel_upgrade_change.SetActive(false);
}
#if UNITY_EDITOR && !SERVICE_HOST
bool kvEquipInFlight;
async void OnClickEquip()
#else
void OnClickEquip()
#endif
{
if (data.config.ID == GContext.container.Resolve<PlayerData>().equipRodID || data.isLock)
{
@@ -244,17 +239,6 @@ public partial class FishingRodPanel : BasePanel
ToastPanel.Show(LocalizationMgr.GetText("UI_ToastPanel_26"));
return;
}
#if UNITY_EDITOR && !SERVICE_HOST
if(game.PlayerDataStorage.PlayerKVGameplayLoop.Enabled)
{
if(kvEquipInFlight) return;
kvEquipInFlight=true;
try { await GContext.container.Resolve<PlayerData>().SetEquipRodIDForKVLoopAsync(data.config.ID); }
catch(System.Exception e) { Debug.LogException(e); return; }
finally { kvEquipInFlight=false; }
}
else
#endif
GContext.container.Resolve<PlayerData>().SetEquipRodID(data.config.ID);
btn_equip_go.gameObject.SetActive(false);
equipped.SetActive(true);

Some files were not shown because too many files have changed in this diff Show More