feat: expand gameplay and state persistence
- add character imprint, awakening, and equipment refinement - add equipment ticket gacha and persistent progression fields - add request-scoped account transactions with crash recovery - persist age confirmation and isolate client plugin patches - separate one-off save repair tools from shipped tooling
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using BepInEx;
|
||||
|
||||
namespace Bd2LocalIdentity;
|
||||
|
||||
// The server's UpdateAgeGate response is intentionally empty. Once that
|
||||
// response succeeds, the only client-side fact needed on the next login is
|
||||
// that this local installation has completed the age-gate flow. Do not keep
|
||||
// the date of birth: it is not consumed by the client after the request and
|
||||
// retaining it would add unnecessary personal data to a local plugin file.
|
||||
internal static class AgeGateState
|
||||
{
|
||||
private const string FileName = "bd2.localidentity.age-gate.state";
|
||||
private const string Content = "format=1\nconfirmed=true\n";
|
||||
private static readonly object Gate = new object();
|
||||
private static bool? confirmed;
|
||||
|
||||
internal static bool IsConfirmed()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (confirmed.HasValue)
|
||||
{
|
||||
return confirmed.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string path = StatePath();
|
||||
confirmed = File.Exists(path) &&
|
||||
string.Equals(File.ReadAllText(path, Encoding.UTF8), Content, StringComparison.Ordinal);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException || ex is UnauthorizedAccessException)
|
||||
{
|
||||
confirmed = false;
|
||||
Plugin.LogWarning("Could not read local age-gate state: " + ex.Message);
|
||||
}
|
||||
|
||||
return confirmed.Value;
|
||||
}
|
||||
}
|
||||
|
||||
internal static void MarkConfirmed()
|
||||
{
|
||||
lock (Gate)
|
||||
{
|
||||
if (confirmed == true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string path = StatePath();
|
||||
string directory = Path.GetDirectoryName(path);
|
||||
if (string.IsNullOrEmpty(directory))
|
||||
{
|
||||
throw new IOException("BepInEx configuration directory is unavailable.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporaryPath = path + "." + Guid.NewGuid().ToString("N") + ".tmp";
|
||||
try
|
||||
{
|
||||
WriteThrough(temporaryPath, Content);
|
||||
if (File.Exists(path))
|
||||
{
|
||||
// Both files are in BepInEx/config, so replacement is an
|
||||
// atomic same-volume operation on the supported Windows client.
|
||||
File.Replace(temporaryPath, path, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(temporaryPath, path);
|
||||
}
|
||||
|
||||
confirmed = true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(temporaryPath))
|
||||
{
|
||||
File.Delete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string StatePath()
|
||||
{
|
||||
return Path.Combine(Paths.ConfigPath, FileName);
|
||||
}
|
||||
|
||||
private static void WriteThrough(string path, string content)
|
||||
{
|
||||
byte[] bytes = new UTF8Encoding(false).GetBytes(content);
|
||||
using FileStream stream = new FileStream(
|
||||
path,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
4096,
|
||||
FileOptions.WriteThrough);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
stream.Flush(true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user