加入用户最近100场战绩记录并实现展示
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public sealed class AllyHeroDeployLedger : MonoBehaviour
|
||||
{
|
||||
public static AllyHeroDeployLedger Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<int, int> deployCountsByHeroId = new Dictionary<int, int>();
|
||||
private bool initialized;
|
||||
private bool loadedFromSave;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
{
|
||||
EnsureInstance();
|
||||
}
|
||||
|
||||
public static AllyHeroDeployLedger EnsureInstance()
|
||||
{
|
||||
if (Instance != null)
|
||||
{
|
||||
return Instance;
|
||||
}
|
||||
|
||||
GameObject host = new GameObject("__runtime_ally_deploy_bridge");
|
||||
host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector;
|
||||
DontDestroyOnLoad(host);
|
||||
Instance = host.AddComponent<AllyHeroDeployLedger>();
|
||||
return Instance;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
InitializeIfNeeded();
|
||||
}
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
if (pauseStatus)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void InitializeIfNeeded()
|
||||
{
|
||||
if (initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AllyHeroDeployLedgerPayload payload;
|
||||
loadedFromSave = AllyHeroDeployLedgerStorage.TryLoad(out payload);
|
||||
RebuildFromPayload(payload);
|
||||
initialized = true;
|
||||
SeedFromHeroAssetsIfNeeded();
|
||||
SyncAllMirrorFlags();
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public int GetDeployCount(int heroId)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
int count;
|
||||
return deployCountsByHeroId.TryGetValue(heroId, out count) ? count : 0;
|
||||
}
|
||||
|
||||
public void IncrementDeployCount(AllyHero_SO hero, int amount = 1)
|
||||
{
|
||||
if (hero == null || hero.ally_heroID <= 0 || amount <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
InitializeIfNeeded();
|
||||
int current = GetDeployCount(hero.ally_heroID);
|
||||
long next = (long)current + amount;
|
||||
deployCountsByHeroId[hero.ally_heroID] = next > int.MaxValue ? int.MaxValue : (int)next;
|
||||
hero.ally_battleDeployCount = deployCountsByHeroId[hero.ally_heroID];
|
||||
MarkDirty(hero);
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void SaveNow()
|
||||
{
|
||||
if (!initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SyncAllMirrorFlags();
|
||||
AllyHeroDeployLedgerStorage.TrySave(BuildPayload());
|
||||
}
|
||||
|
||||
private void RebuildFromPayload(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
deployCountsByHeroId.Clear();
|
||||
if (payload == null || payload.entries == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < payload.entries.Count; i++)
|
||||
{
|
||||
AllyHeroDeployEntry entry = payload.entries[i];
|
||||
if (entry == null || entry.heroId <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
deployCountsByHeroId[entry.heroId] = Mathf.Max(0, entry.deployCount);
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedFromHeroAssetsIfNeeded()
|
||||
{
|
||||
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
|
||||
bool changed = false;
|
||||
|
||||
for (int i = 0; i < heroes.Length; i++)
|
||||
{
|
||||
AllyHero_SO hero = heroes[i];
|
||||
if (hero == null || hero.ally_heroID <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (deployCountsByHeroId.ContainsKey(hero.ally_heroID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hero.ally_battleDeployCount <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
deployCountsByHeroId[hero.ally_heroID] = Mathf.Max(0, hero.ally_battleDeployCount);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed && !loadedFromSave)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncAllMirrorFlags()
|
||||
{
|
||||
AllyHero_SO[] heroes = Resources.LoadAll<AllyHero_SO>(string.Empty);
|
||||
for (int i = 0; i < heroes.Length; i++)
|
||||
{
|
||||
AllyHero_SO hero = heroes[i];
|
||||
if (hero == null || hero.ally_heroID <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int count;
|
||||
if (!deployCountsByHeroId.TryGetValue(hero.ally_heroID, out count))
|
||||
{
|
||||
count = 0;
|
||||
}
|
||||
|
||||
if (hero.ally_battleDeployCount == count)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hero.ally_battleDeployCount = count;
|
||||
MarkDirty(hero);
|
||||
}
|
||||
}
|
||||
|
||||
private AllyHeroDeployLedgerPayload BuildPayload()
|
||||
{
|
||||
AllyHeroDeployLedgerPayload payload = AllyHeroDeployLedgerStorage.CreateDefaultPayload();
|
||||
foreach (KeyValuePair<int, int> pair in deployCountsByHeroId)
|
||||
{
|
||||
payload.entries.Add(new AllyHeroDeployEntry
|
||||
{
|
||||
heroId = pair.Key,
|
||||
deployCount = Mathf.Max(0, pair.Value)
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private static void MarkDirty(AllyHero_SO hero)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (hero != null)
|
||||
{
|
||||
EditorUtility.SetDirty(hero);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4b993c80d44994489f40e5a81dda7b3
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployEntry
|
||||
{
|
||||
public int heroId;
|
||||
public int deployCount;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployLedgerPayload
|
||||
{
|
||||
public int version;
|
||||
public long lastUpdatedUtcTicks;
|
||||
public List<AllyHeroDeployEntry> entries = new List<AllyHeroDeployEntry>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class AllyHeroDeployLedgerEnvelope
|
||||
{
|
||||
public string payload;
|
||||
public string signature;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e25750439aee8f4e93c609697703d8a
|
||||
@@ -0,0 +1,187 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
public static class AllyHeroDeployLedgerStorage
|
||||
{
|
||||
private const string SecretSeed = "ban_total.ally_hero_deploy_ledger.v1";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private const string MainFileName = ".ahd.dat";
|
||||
private const string BackupFileName = ".ahd.bak";
|
||||
private const string TempFileName = ".ahd.tmp";
|
||||
|
||||
private static string VaultDirectoryPath => Path.Combine(Application.persistentDataPath, VaultDirectoryName);
|
||||
private static string MainFilePath => Path.Combine(VaultDirectoryPath, MainFileName);
|
||||
private static string BackupFilePath => Path.Combine(VaultDirectoryPath, BackupFileName);
|
||||
private static string TempFilePath => Path.Combine(VaultDirectoryPath, TempFileName);
|
||||
|
||||
public static bool TryLoad(out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TrySave(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(VaultDirectoryPath);
|
||||
TryHidePath(VaultDirectoryPath);
|
||||
|
||||
string envelopeJson = BuildEnvelopeJson(payload);
|
||||
File.WriteAllText(TempFilePath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(TempFilePath);
|
||||
|
||||
if (File.Exists(MainFilePath))
|
||||
{
|
||||
File.Copy(MainFilePath, BackupFilePath, true);
|
||||
TryHidePath(BackupFilePath);
|
||||
}
|
||||
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save failed: " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static AllyHeroDeployLedgerPayload CreateDefaultPayload()
|
||||
{
|
||||
return new AllyHeroDeployLedgerPayload
|
||||
{
|
||||
version = 1,
|
||||
lastUpdatedUtcTicks = DateTime.UtcNow.Ticks,
|
||||
entries = new System.Collections.Generic.List<AllyHeroDeployEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadPayload(string path, out AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string envelopeJson = File.ReadAllText(path, Encoding.UTF8);
|
||||
AllyHeroDeployLedgerEnvelope envelope = JsonUtility.FromJson<AllyHeroDeployLedgerEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
byte[] plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
string payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
AllyHeroDeployLedgerPayload loadedPayload = JsonUtility.FromJson<AllyHeroDeployLedgerPayload>(payloadJson);
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = loadedPayload;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning("[AllyHeroDeployLedgerStorage] Load failed from '" + path + "': " + ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(AllyHeroDeployLedgerPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
string payloadJson = JsonUtility.ToJson(payload, false);
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
byte[] encryptedBytes = XorTransform(plainBytes, BuildKeyBytes());
|
||||
string payloadBase64 = Convert.ToBase64String(encryptedBytes);
|
||||
|
||||
AllyHeroDeployLedgerEnvelope envelope = new AllyHeroDeployLedgerEnvelope
|
||||
{
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(payloadBase64)
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
byte[] bytes = Encoding.UTF8.GetBytes(signText);
|
||||
byte[] hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
using (SHA256 sha = SHA256.Create())
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
byte[] result = new byte[source.Length];
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
result[i] = (byte)(source[i] ^ key[i % key.Length]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
FileAttributes attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eb496e1becc09464eb0ca23b9a69557d
|
||||
@@ -109,6 +109,25 @@ public sealed class DushMaterialLedger : MonoBehaviour
|
||||
ChangeCount(DushMaterialCatalog.GetKey(kind), amount);
|
||||
}
|
||||
|
||||
public bool TryConsume(DushMaterialKind kind, int amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = DushMaterialCatalog.GetKey(kind);
|
||||
int current = GetCountByKey(key);
|
||||
if (current < amount)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
ChangeCount(key, -amount);
|
||||
DailyTaskEventHub.ReportUseItem(amount);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool SeedFromPlayerSo(Player_SO playerData, bool overwriteExistingCounts)
|
||||
{
|
||||
if (playerData == null)
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
public static class LegacyPlainSaveMigrator
|
||||
{
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void MigrateLegacyPlainFiles()
|
||||
{
|
||||
string root = Application.persistentDataPath;
|
||||
MigrateSingleFile("daily_task", "runtime", Path.Combine(root, "daily_tasks.json"));
|
||||
MigrateSingleFile("mail_state", "runtime", Path.Combine(root, "mail_state.json"));
|
||||
MigrateSingleFile("store_state", "runtime", Path.Combine(root, "storeSystem_state.json"));
|
||||
|
||||
MigratePattern("song_runtime", root, "SongData_*.json", fileNameWithoutExt =>
|
||||
{
|
||||
if (fileNameWithoutExt.StartsWith("SongData_"))
|
||||
{
|
||||
return fileNameWithoutExt.Substring("SongData_".Length);
|
||||
}
|
||||
|
||||
return fileNameWithoutExt;
|
||||
});
|
||||
|
||||
MigratePattern("song_export", Path.Combine(root, "SongDataJson"), "*.json", fileNameWithoutExt => fileNameWithoutExt);
|
||||
MigratePattern("song_export", Path.Combine(root, "song_json_export"), "*.json", fileNameWithoutExt => fileNameWithoutExt);
|
||||
}
|
||||
|
||||
private static void MigrateSingleFile(string category, string key, string legacyPath)
|
||||
{
|
||||
if (!File.Exists(legacyPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
File.Delete(legacyPath);
|
||||
return;
|
||||
}
|
||||
|
||||
SecureSaveVault.SaveRawJson(category, key, json, legacyPath);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[LegacyPlainSaveMigrator] Failed to migrate {legacyPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void MigratePattern(string category, string directory, string searchPattern, System.Func<string, string> keyResolver)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] files = Directory.GetFiles(directory, searchPattern, SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < files.Length; i++)
|
||||
{
|
||||
string legacyPath = files[i];
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
File.Delete(legacyPath);
|
||||
continue;
|
||||
}
|
||||
|
||||
string key = keyResolver != null ? keyResolver(Path.GetFileNameWithoutExtension(legacyPath)) : Path.GetFileNameWithoutExtension(legacyPath);
|
||||
SecureSaveVault.SaveRawJson(category, key, json, legacyPath);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[LegacyPlainSaveMigrator] Failed to migrate {legacyPath}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
TryDeleteDirectoryIfEmpty(directory);
|
||||
}
|
||||
|
||||
private static void TryDeleteDirectoryIfEmpty(string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Directory.GetFiles(directory).Length == 0 && Directory.GetDirectories(directory).Length == 0)
|
||||
{
|
||||
Directory.Delete(directory, false);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f29bda46e3dc3ae4d9bf7aa6c11bcadd
|
||||
@@ -0,0 +1,23 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class RecentPlayRecord
|
||||
{
|
||||
public string playedAt;
|
||||
public int songID;
|
||||
public string songName;
|
||||
public string difficultyDisplay;
|
||||
public float accuracy;
|
||||
public float srks;
|
||||
public int totalScore;
|
||||
public bool scoreReadable;
|
||||
public bool wasEarlySettlement;
|
||||
public bool wasAllPerfect;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class RecentPlayHistoryPayload
|
||||
{
|
||||
public List<RecentPlayRecord> records = new List<RecentPlayRecord>();
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f13cbff8f75ffe64aa2aed4e7cff0609
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
public static class RecentPlayHistoryStore
|
||||
{
|
||||
private const string Category = "recent_play_history";
|
||||
private const string Key = "runs_v1";
|
||||
private const int MaxRecordCount = 100;
|
||||
|
||||
public static IReadOnlyList<RecentPlayRecord> GetRecords()
|
||||
{
|
||||
return LoadPayload().records;
|
||||
}
|
||||
|
||||
public static void Push(RecentPlayRecord record)
|
||||
{
|
||||
if (record == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = LoadPayload();
|
||||
if (payload.records == null)
|
||||
{
|
||||
payload.records = new List<RecentPlayRecord>();
|
||||
}
|
||||
|
||||
payload.records.Insert(0, record);
|
||||
if (payload.records.Count > MaxRecordCount)
|
||||
{
|
||||
payload.records.RemoveRange(MaxRecordCount, payload.records.Count - MaxRecordCount);
|
||||
}
|
||||
|
||||
SecureSaveVault.SaveJson(Category, Key, payload);
|
||||
}
|
||||
|
||||
public static void Clear()
|
||||
{
|
||||
SecureSaveVault.Delete(Category, Key);
|
||||
}
|
||||
|
||||
private static RecentPlayHistoryPayload LoadPayload()
|
||||
{
|
||||
RecentPlayHistoryPayload payload;
|
||||
if (!SecureSaveVault.TryLoadJson(Category, Key, out payload) || payload == null)
|
||||
{
|
||||
payload = new RecentPlayHistoryPayload();
|
||||
}
|
||||
|
||||
if (payload.records == null)
|
||||
{
|
||||
payload.records = new List<RecentPlayRecord>();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 68da4f91f4d88174fb8da3ea658c1672
|
||||
@@ -0,0 +1,555 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
[Serializable]
|
||||
public class SecureSaveEnvelope
|
||||
{
|
||||
public int version;
|
||||
public string payload;
|
||||
public string signature;
|
||||
public long savedUtcTicks;
|
||||
}
|
||||
|
||||
public static class SecureSaveVault
|
||||
{
|
||||
private const string SecretSeed = "ban_total.secure_save_v2";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private static bool s_dpapiInitialized;
|
||||
private static bool s_dpapiSupported;
|
||||
private static MethodInfo s_dpapiProtectMethod;
|
||||
private static MethodInfo s_dpapiUnprotectMethod;
|
||||
private static object s_dpapiCurrentUserScope;
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
}
|
||||
|
||||
public static bool SaveJson<T>(string category, string key, T data, string legacyPlainPath = null)
|
||||
{
|
||||
if (!typeof(T).IsValueType && (object)data == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string json = JsonUtility.ToJson(data, false);
|
||||
return SaveRawJson(category, key, json, legacyPlainPath);
|
||||
}
|
||||
|
||||
public static bool TryLoadJson<T>(string category, string key, out T data, string legacyPlainPath = null)
|
||||
{
|
||||
data = default(T);
|
||||
string json;
|
||||
if (!TryLoadRawJson(category, key, out json, legacyPlainPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
data = JsonUtility.FromJson<T>(json);
|
||||
if (typeof(T).IsValueType)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return (object)data != null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] JSON parse failed: {ex.Message}");
|
||||
data = default(T);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool SaveRawJson(string category, string key, string json, string legacyPlainPath = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key) || json == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mainPath = GetFilePath(category, key, ".dat");
|
||||
string backupPath = GetFilePath(category, key, ".bak");
|
||||
string tempPath = GetFilePath(category, key, ".tmp");
|
||||
|
||||
try
|
||||
{
|
||||
string directory = Path.GetDirectoryName(mainPath);
|
||||
Directory.CreateDirectory(directory);
|
||||
TryHidePath(directory);
|
||||
|
||||
string envelopeJson = BuildEnvelopeJson(category, key, json);
|
||||
File.WriteAllText(tempPath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(tempPath);
|
||||
|
||||
if (File.Exists(mainPath))
|
||||
{
|
||||
File.Copy(mainPath, backupPath, true);
|
||||
TryHidePath(backupPath);
|
||||
}
|
||||
|
||||
File.Copy(tempPath, mainPath, true);
|
||||
TryHidePath(mainPath);
|
||||
File.Delete(tempPath);
|
||||
DeleteLegacyPlainFile(legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Save failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryLoadRawJson(string category, string key, out string json, string legacyPlainPath = null)
|
||||
{
|
||||
json = null;
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string mainPath = GetFilePath(category, key, ".dat");
|
||||
string backupPath = GetFilePath(category, key, ".bak");
|
||||
|
||||
if (TryReadEncryptedFile(category, key, mainPath, out json))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadEncryptedFile(category, key, backupPath, out json))
|
||||
{
|
||||
SaveRawJson(category, key, json, legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyPlainPath) && File.Exists(legacyPlainPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
json = File.ReadAllText(legacyPlainPath, Encoding.UTF8);
|
||||
if (!string.IsNullOrEmpty(json))
|
||||
{
|
||||
SaveRawJson(category, key, json, legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Legacy migration failed ({legacyPlainPath}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool Delete(string category, string key, string legacyPlainPath = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
DeleteIfExists(GetFilePath(category, key, ".dat"));
|
||||
DeleteIfExists(GetFilePath(category, key, ".bak"));
|
||||
DeleteIfExists(GetFilePath(category, key, ".tmp"));
|
||||
DeleteLegacyPlainFile(legacyPlainPath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Delete failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<string> LoadAllRawJson(string category, string legacyDirectory = null, string legacySearchPattern = "*.json")
|
||||
{
|
||||
var result = new List<string>();
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
if (Directory.Exists(categoryDirectory))
|
||||
{
|
||||
string[] encryptedFiles = Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < encryptedFiles.Length; i++)
|
||||
{
|
||||
string path = encryptedFiles[i];
|
||||
string key = ExtractKeyFromFileName(path);
|
||||
if (string.IsNullOrEmpty(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string json;
|
||||
if (TryReadEncryptedFile(category, key, path, out json) && !string.IsNullOrEmpty(json))
|
||||
{
|
||||
result.Add(json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(legacyDirectory) && Directory.Exists(legacyDirectory))
|
||||
{
|
||||
string[] legacyFiles = Directory.GetFiles(legacyDirectory, legacySearchPattern, SearchOption.TopDirectoryOnly);
|
||||
for (int i = 0; i < legacyFiles.Length; i++)
|
||||
{
|
||||
string legacyPath = legacyFiles[i];
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(legacyPath, Encoding.UTF8);
|
||||
if (string.IsNullOrEmpty(json))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string legacyKey = Path.GetFileNameWithoutExtension(legacyPath);
|
||||
SaveRawJson(category, legacyKey, json, legacyPath);
|
||||
result.Add(json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Legacy bulk migration failed ({legacyPath}): {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static int CountEncryptedFiles(string category)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
if (!Directory.Exists(categoryDirectory))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Directory.GetFiles(categoryDirectory, "*.dat", SearchOption.TopDirectoryOnly).Length;
|
||||
}
|
||||
|
||||
private static bool TryReadEncryptedFile(string category, string key, string filePath, out string json)
|
||||
{
|
||||
json = null;
|
||||
if (!File.Exists(filePath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string envelopeJson = File.ReadAllText(filePath, Encoding.UTF8);
|
||||
var envelope = JsonUtility.FromJson<SecureSaveEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string expectedSignature = ComputeSignature(category, envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Signature mismatch ({category}/{key}). Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] protectedBytes = Convert.FromBase64String(envelope.payload);
|
||||
byte[] plainBytes;
|
||||
if (!TryUnprotectBytes(category, key, protectedBytes, out plainBytes))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
json = Encoding.UTF8.GetString(plainBytes);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Read failed ({category}/{key}): {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(string category, string key, string json)
|
||||
{
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(json);
|
||||
byte[] protectedBytes = ProtectBytes(category, key, plainBytes);
|
||||
string payloadBase64 = Convert.ToBase64String(protectedBytes);
|
||||
|
||||
var envelope = new SecureSaveEnvelope
|
||||
{
|
||||
version = 2,
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(category, payloadBase64),
|
||||
savedUtcTicks = DateTime.UtcNow.Ticks
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string GetCategoryDirectory(string category)
|
||||
{
|
||||
string safeCategory = ShortHash("cat|" + category);
|
||||
return Path.Combine(VaultDirectoryPath, "." + safeCategory);
|
||||
}
|
||||
|
||||
private static string GetFilePath(string category, string key, string extension)
|
||||
{
|
||||
string categoryDirectory = GetCategoryDirectory(category);
|
||||
string safeKey = ShortHash("key|" + key);
|
||||
return Path.Combine(categoryDirectory, "." + safeKey + extension);
|
||||
}
|
||||
|
||||
private static string ExtractKeyFromFileName(string path)
|
||||
{
|
||||
string fileName = Path.GetFileNameWithoutExtension(path);
|
||||
if (string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return fileName.StartsWith(".") ? fileName.Substring(1) : fileName;
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string category, string payloadBase64)
|
||||
{
|
||||
string signText = payloadBase64 + "|" + category + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(signText));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] ProtectBytes(string category, string key, byte[] plainBytes)
|
||||
{
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
byte[] dpapiBytes;
|
||||
if (TryProtectWithDpapi(category, plainBytes, out dpapiBytes))
|
||||
{
|
||||
return dpapiBytes;
|
||||
}
|
||||
#endif
|
||||
using (var aes = Aes.Create())
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
aes.GenerateIV();
|
||||
using (var encryptor = aes.CreateEncryptor())
|
||||
{
|
||||
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
byte[] result = new byte[aes.IV.Length + cipherBytes.Length];
|
||||
Buffer.BlockCopy(aes.IV, 0, result, 0, aes.IV.Length);
|
||||
Buffer.BlockCopy(cipherBytes, 0, result, aes.IV.Length, cipherBytes.Length);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryUnprotectBytes(string category, string key, byte[] protectedBytes, out byte[] plainBytes)
|
||||
{
|
||||
plainBytes = null;
|
||||
try
|
||||
{
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
if (TryUnprotectWithDpapi(category, protectedBytes, out plainBytes))
|
||||
{
|
||||
return plainBytes != null;
|
||||
}
|
||||
#endif
|
||||
using (var aes = Aes.Create())
|
||||
{
|
||||
aes.Mode = CipherMode.CBC;
|
||||
aes.Padding = PaddingMode.PKCS7;
|
||||
aes.Key = BuildAesKey(category, key);
|
||||
int ivLength = aes.BlockSize / 8;
|
||||
if (protectedBytes == null || protectedBytes.Length <= ivLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] iv = new byte[ivLength];
|
||||
byte[] cipher = new byte[protectedBytes.Length - ivLength];
|
||||
Buffer.BlockCopy(protectedBytes, 0, iv, 0, ivLength);
|
||||
Buffer.BlockCopy(protectedBytes, ivLength, cipher, 0, cipher.Length);
|
||||
aes.IV = iv;
|
||||
using (var decryptor = aes.CreateDecryptor())
|
||||
{
|
||||
plainBytes = decryptor.TransformFinalBlock(cipher, 0, cipher.Length);
|
||||
return plainBytes != null;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] Decrypt failed ({category}/{key}): {ex.Message}");
|
||||
plainBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR_WIN || UNITY_STANDALONE_WIN
|
||||
private static bool TryProtectWithDpapi(string category, byte[] plainBytes, out byte[] protectedBytes)
|
||||
{
|
||||
protectedBytes = null;
|
||||
if (!EnsureDpapi())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
protectedBytes = s_dpapiProtectMethod.Invoke(null, new object[] { plainBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
|
||||
return protectedBytes != null && protectedBytes.Length > 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] DPAPI protect failed, fallback to AES: {ex.Message}");
|
||||
protectedBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryUnprotectWithDpapi(string category, byte[] protectedBytes, out byte[] plainBytes)
|
||||
{
|
||||
plainBytes = null;
|
||||
if (!EnsureDpapi())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
plainBytes = s_dpapiUnprotectMethod.Invoke(null, new object[] { protectedBytes, BuildEntropy(category), s_dpapiCurrentUserScope }) as byte[];
|
||||
return plainBytes != null && plainBytes.Length > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
plainBytes = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool EnsureDpapi()
|
||||
{
|
||||
if (s_dpapiInitialized)
|
||||
{
|
||||
return s_dpapiSupported;
|
||||
}
|
||||
|
||||
s_dpapiInitialized = true;
|
||||
try
|
||||
{
|
||||
Type protectedDataType =
|
||||
Type.GetType("System.Security.Cryptography.ProtectedData, System.Security.Cryptography.ProtectedData") ??
|
||||
Type.GetType("System.Security.Cryptography.ProtectedData, System.Security");
|
||||
Type scopeType =
|
||||
Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security.Cryptography.ProtectedData") ??
|
||||
Type.GetType("System.Security.Cryptography.DataProtectionScope, System.Security");
|
||||
|
||||
if (protectedDataType == null || scopeType == null)
|
||||
{
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
s_dpapiProtectMethod = protectedDataType.GetMethod("Protect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null);
|
||||
s_dpapiUnprotectMethod = protectedDataType.GetMethod("Unprotect", BindingFlags.Public | BindingFlags.Static, null, new[] { typeof(byte[]), typeof(byte[]), scopeType }, null);
|
||||
|
||||
if (s_dpapiProtectMethod == null || s_dpapiUnprotectMethod == null)
|
||||
{
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
s_dpapiCurrentUserScope = Enum.Parse(scopeType, "CurrentUser");
|
||||
s_dpapiSupported = s_dpapiCurrentUserScope != null;
|
||||
return s_dpapiSupported;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[SecureSaveVault] DPAPI initialize failed, fallback to AES: {ex.Message}");
|
||||
s_dpapiSupported = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static byte[] BuildEntropy(string category)
|
||||
{
|
||||
string seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed + "|" + category;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildAesKey(string category, string key)
|
||||
{
|
||||
return BuildEntropy(category);
|
||||
}
|
||||
|
||||
private static string ShortHash(string value)
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
byte[] hash = sha.ComputeHash(Encoding.UTF8.GetBytes(value + "|" + Application.identifier + "|" + SecretSeed));
|
||||
return BitConverter.ToString(hash, 0, 12).Replace("-", string.Empty).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteLegacyPlainFile(string legacyPlainPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(legacyPlainPath) || !File.Exists(legacyPlainPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DeleteIfExists(legacyPlainPath);
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(path) && File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 79fb5f18bfcf1674993076d6b5e5bb9d
|
||||
@@ -32,6 +32,12 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RequiresSinglePurchase(itemSO) && packageCount > 1)
|
||||
{
|
||||
failureMessage = "该物品仅支持单份购买";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (itemSO.costRequirements == null || itemSO.costRequirements.Count == 0)
|
||||
{
|
||||
failureMessage = "不可购买";
|
||||
@@ -45,13 +51,6 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleKind kind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out kind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
|
||||
long longGrantedCount = (long)Mathf.Max(1, itemSO.itemSinglePurchaseQty) * packageCount;
|
||||
if (longGrantedCount > int.MaxValue)
|
||||
{
|
||||
@@ -74,9 +73,13 @@ public static class StoreExpBottlePurchaseService
|
||||
}
|
||||
|
||||
int totalCost = (int)totalCostLong;
|
||||
if (!ValidateGrantTarget(itemSO, out failureMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
PlayerEconomyLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
ExpBottleLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
StoreOwnershipLedger.EnsureInstance().InitializeIfNeeded();
|
||||
|
||||
if (!PlayerEconomyLedger.EnsureInstance().HasEnoughCoins(totalCost))
|
||||
{
|
||||
@@ -90,14 +93,95 @@ public static class StoreExpBottlePurchaseService
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleLedger.EnsureInstance().Add(kind, grantedCount);
|
||||
if (!GrantPurchasedItem(playerData, itemSO, grantedCount, out failureMessage))
|
||||
{
|
||||
PlayerEconomyLedger.EnsureInstance().AddCoins(totalCost);
|
||||
return false;
|
||||
}
|
||||
|
||||
DebugPurchaseSuccess(itemSO, totalCost, grantedCount);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool RequiresSinglePurchase(storeItemSO itemSO)
|
||||
{
|
||||
if (itemSO == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return itemSO.itemType == storeItemSO.ItemType.character
|
||||
|| itemSO.itemType == storeItemSO.ItemType.song
|
||||
|| itemSO.itemType == storeItemSO.ItemType.storyPassage;
|
||||
}
|
||||
|
||||
private static bool ValidateGrantTarget(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.consumable:
|
||||
ExpBottleKind bottleKind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out bottleKind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return StoreOwnershipLedger.EnsureInstance().TryGrantOwnershipPreview(itemSO, out failureMessage);
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool GrantPurchasedItem(Player_SO playerData, storeItemSO itemSO, int grantedCount, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.consumable:
|
||||
ExpBottleKind bottleKind;
|
||||
if (!StoreExpBottleGrantResolver.TryResolve(itemSO, out bottleKind))
|
||||
{
|
||||
failureMessage = "当前仅支持经验瓶发放";
|
||||
return false;
|
||||
}
|
||||
|
||||
ExpBottleLedger.EnsureInstance().AttachPlayerData(playerData);
|
||||
ExpBottleLedger.EnsureInstance().Add(bottleKind, grantedCount);
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return StoreOwnershipLedger.EnsureInstance().TryGrantOwnership(itemSO, out failureMessage);
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DebugPurchaseSuccess(storeItemSO itemSO, int totalCost, int grantedCount)
|
||||
{
|
||||
var snapshot = ExpBottleLedger.EnsureInstance().GetSnapshot();
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("[StorePurchase] 已成功购买:");
|
||||
builder.Append(itemSO != null ? itemSO.itemName : "未知物品");
|
||||
@@ -105,6 +189,15 @@ public static class StoreExpBottlePurchaseService
|
||||
builder.Append(totalCost);
|
||||
builder.Append(" | 发放数量=");
|
||||
builder.Append(grantedCount);
|
||||
|
||||
if (itemSO != null && itemSO.itemType != storeItemSO.ItemType.consumable)
|
||||
{
|
||||
builder.Append(" | 已写入OwnershipSave");
|
||||
Debug.Log(builder.ToString());
|
||||
return;
|
||||
}
|
||||
|
||||
var snapshot = ExpBottleLedger.EnsureInstance().GetSnapshot();
|
||||
builder.Append(" | 经验瓶库存:");
|
||||
|
||||
bool appendedAny = false;
|
||||
|
||||
@@ -0,0 +1,656 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
#endif
|
||||
|
||||
public sealed class StoreOwnershipLedger : MonoBehaviour
|
||||
{
|
||||
private const string RuntimeStoreItemResourcesPath = "so/storeSO";
|
||||
|
||||
public static StoreOwnershipLedger Instance { get; private set; }
|
||||
|
||||
private readonly Dictionary<int, StoreOwnershipEntry> entriesByItemId = new Dictionary<int, StoreOwnershipEntry>();
|
||||
private readonly List<storeItemSO> cachedStoreItems = new List<storeItemSO>();
|
||||
private bool initialized;
|
||||
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
|
||||
private static void Bootstrap()
|
||||
{
|
||||
EnsureInstance();
|
||||
}
|
||||
|
||||
public static StoreOwnershipLedger EnsureInstance()
|
||||
{
|
||||
if (Instance != null)
|
||||
{
|
||||
return Instance;
|
||||
}
|
||||
|
||||
var host = new GameObject("__runtime_ownership_bridge");
|
||||
host.hideFlags = HideFlags.HideInHierarchy | HideFlags.HideInInspector;
|
||||
DontDestroyOnLoad(host);
|
||||
Instance = host.AddComponent<StoreOwnershipLedger>();
|
||||
return Instance;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (Instance != null && Instance != this)
|
||||
{
|
||||
Destroy(gameObject);
|
||||
return;
|
||||
}
|
||||
|
||||
Instance = this;
|
||||
DontDestroyOnLoad(gameObject);
|
||||
InitializeIfNeeded();
|
||||
}
|
||||
|
||||
private void OnApplicationPause(bool pauseStatus)
|
||||
{
|
||||
if (pauseStatus)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void InitializeIfNeeded()
|
||||
{
|
||||
if (initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoreOwnershipPayload payload;
|
||||
StoreOwnershipStorage.TryLoad(out payload);
|
||||
RebuildFromPayload(payload);
|
||||
LoadStoreItems();
|
||||
SeedFromCurrentMirrorFlags();
|
||||
SyncAllMirrorFlags();
|
||||
initialized = true;
|
||||
SaveNow();
|
||||
}
|
||||
|
||||
public void ForceSyncMirrorFlags()
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
LoadStoreItems();
|
||||
SyncAllMirrorFlags();
|
||||
}
|
||||
|
||||
public bool IsOwned(storeItemSO itemSO)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
if (itemSO == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry entry;
|
||||
entriesByItemId.TryGetValue(itemSO.itemID, out entry);
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
return (entry != null && entry.owned) || (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked);
|
||||
case storeItemSO.ItemType.song:
|
||||
return (entry != null && entry.owned) || (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked);
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return IsStoryGrantOwned(itemSO, entry);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGrantOwnership(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
failureMessage = string.Empty;
|
||||
|
||||
if (!ValidateGrantTarget(itemSO, out failureMessage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var entry = GetOrCreateEntry(itemSO.itemID);
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
case storeItemSO.ItemType.song:
|
||||
entry.owned = true;
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
GrantStoryOwnership(itemSO, entry);
|
||||
break;
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
|
||||
ApplyEntryToItem(itemSO, entry);
|
||||
SaveNow();
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryGrantOwnershipPreview(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
InitializeIfNeeded();
|
||||
return ValidateGrantTarget(itemSO, out failureMessage);
|
||||
}
|
||||
|
||||
public void SaveNow()
|
||||
{
|
||||
if (!initialized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StoreOwnershipStorage.TrySave(CreatePayload());
|
||||
}
|
||||
|
||||
private void RebuildFromPayload(StoreOwnershipPayload payload)
|
||||
{
|
||||
entriesByItemId.Clear();
|
||||
if (payload == null || payload.entries == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < payload.entries.Count; i++)
|
||||
{
|
||||
var entry = payload.entries[i];
|
||||
if (entry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.unlockedStorySonIds == null)
|
||||
{
|
||||
entry.unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
entriesByItemId[entry.storeItemId] = entry;
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipPayload CreatePayload()
|
||||
{
|
||||
var payload = StoreOwnershipStorage.CreateDefaultPayload();
|
||||
foreach (var pair in entriesByItemId)
|
||||
{
|
||||
payload.entries.Add(CloneEntry(pair.Value));
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
private void LoadStoreItems()
|
||||
{
|
||||
cachedStoreItems.Clear();
|
||||
var loadedItems = Resources.LoadAll<storeItemSO>(RuntimeStoreItemResourcesPath);
|
||||
var seen = new HashSet<int>();
|
||||
for (int i = 0; i < loadedItems.Length; i++)
|
||||
{
|
||||
var item = loadedItems[i];
|
||||
if (item == null || !seen.Add(item.itemID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
cachedStoreItems.Add(item);
|
||||
}
|
||||
}
|
||||
|
||||
private void SeedFromCurrentMirrorFlags()
|
||||
{
|
||||
bool changed = false;
|
||||
for (int i = 0; i < cachedStoreItems.Count; i++)
|
||||
{
|
||||
var item = cachedStoreItems[i];
|
||||
if (item == null || entriesByItemId.ContainsKey(item.itemID))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry seededEntry = TrySeedEntry(item);
|
||||
if (seededEntry == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entriesByItemId[item.itemID] = seededEntry;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (changed)
|
||||
{
|
||||
SaveNow();
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry TrySeedEntry(storeItemSO itemSO)
|
||||
{
|
||||
if (itemSO == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (itemSO.associatedAllyHero != null && itemSO.associatedAllyHero.isUnlocked)
|
||||
{
|
||||
return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true };
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (itemSO.associatedSong != null && itemSO.associatedSong.isUnlocked)
|
||||
{
|
||||
return new StoreOwnershipEntry { storeItemId = itemSO.itemID, owned = true };
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
return TrySeedStoryEntry(itemSO);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry TrySeedStoryEntry(storeItemSO itemSO)
|
||||
{
|
||||
var story = itemSO != null ? itemSO.associatedStoryPassage : null;
|
||||
if (story == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entry = new StoreOwnershipEntry { storeItemId = itemSO.itemID };
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
entry.owned = story.isUnlocked;
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
if (HasStorySonUnlocked(story, itemSO.associatedStorySonId))
|
||||
{
|
||||
entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
if (story.isUnlocked || HasStorySonUnlocked(story, itemSO.associatedStorySonId))
|
||||
{
|
||||
entry.owned = true;
|
||||
if (itemSO.associatedStorySonId >= 0)
|
||||
{
|
||||
entry.unlockedStorySonIds.Add(itemSO.associatedStorySonId);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
if (story.isUnlocked || AreAllStorySonsUnlocked(story))
|
||||
{
|
||||
entry.owned = true;
|
||||
AddAllStorySonIds(story, entry.unlockedStorySonIds);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return entry.owned || entry.unlockedStorySonIds.Count > 0 ? entry : null;
|
||||
}
|
||||
|
||||
private void SyncAllMirrorFlags()
|
||||
{
|
||||
for (int i = 0; i < cachedStoreItems.Count; i++)
|
||||
{
|
||||
var item = cachedStoreItems[i];
|
||||
if (item == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
StoreOwnershipEntry entry;
|
||||
if (!entriesByItemId.TryGetValue(item.itemID, out entry))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ApplyEntryToItem(item, entry);
|
||||
}
|
||||
}
|
||||
|
||||
private bool ValidateGrantTarget(storeItemSO itemSO, out string failureMessage)
|
||||
{
|
||||
failureMessage = string.Empty;
|
||||
if (itemSO == null)
|
||||
{
|
||||
failureMessage = "商品数据丢失";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (IsOwned(itemSO))
|
||||
{
|
||||
failureMessage = "物品已解锁";
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (itemSO.associatedAllyHero == null)
|
||||
{
|
||||
failureMessage = "角色数据未配置";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (itemSO.associatedSong == null)
|
||||
{
|
||||
failureMessage = "歌曲数据未配置";
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
if (itemSO.associatedStoryPassage == null)
|
||||
{
|
||||
failureMessage = "剧情数据未配置";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (RequiresSpecificStorySon(itemSO.storyPassageGrantMode))
|
||||
{
|
||||
if (itemSO.associatedStorySonId < 0 || FindStorySonIndex(itemSO.associatedStoryPassage, itemSO.associatedStorySonId) < 0)
|
||||
{
|
||||
failureMessage = "剧情子章节未配置";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
|
||||
default:
|
||||
failureMessage = "当前未配置发放逻辑";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void GrantStoryOwnership(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (entry.unlockedStorySonIds == null)
|
||||
{
|
||||
entry.unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
entry.owned = true;
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId);
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
entry.owned = true;
|
||||
AddStorySonId(entry.unlockedStorySonIds, itemSO.associatedStorySonId);
|
||||
break;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
entry.owned = true;
|
||||
AddAllStorySonIds(itemSO.associatedStoryPassage, entry.unlockedStorySonIds);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyEntryToItem(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (itemSO == null || entry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (itemSO.itemType)
|
||||
{
|
||||
case storeItemSO.ItemType.character:
|
||||
if (entry.owned && itemSO.associatedAllyHero != null)
|
||||
{
|
||||
itemSO.associatedAllyHero.isUnlocked = true;
|
||||
MarkDirty(itemSO.associatedAllyHero);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.song:
|
||||
if (entry.owned && itemSO.associatedSong != null)
|
||||
{
|
||||
itemSO.associatedSong.isUnlocked = true;
|
||||
MarkDirty(itemSO.associatedSong);
|
||||
}
|
||||
break;
|
||||
|
||||
case storeItemSO.ItemType.storyPassage:
|
||||
ApplyStoryEntry(itemSO.associatedStoryPassage, entry);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void ApplyStoryEntry(notebook_faterType story, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (story == null || entry == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.owned || (entry.unlockedStorySonIds != null && entry.unlockedStorySonIds.Count > 0))
|
||||
{
|
||||
story.isUnlocked = true;
|
||||
MarkDirty(story);
|
||||
}
|
||||
|
||||
if (entry.unlockedStorySonIds == null || story.sonList == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < entry.unlockedStorySonIds.Count; i++)
|
||||
{
|
||||
int targetSonId = entry.unlockedStorySonIds[i];
|
||||
int sonIndex = FindStorySonIndex(story, targetSonId);
|
||||
if (sonIndex < 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
story.sonList[sonIndex].son_isUnlocked = true;
|
||||
MarkDirty(story);
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsStoryGrantOwned(storeItemSO itemSO, StoreOwnershipEntry entry)
|
||||
{
|
||||
if (itemSO == null || itemSO.associatedStoryPassage == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (itemSO.storyPassageGrantMode)
|
||||
{
|
||||
case storeItemSO.StoryPassageGrantMode.fatherOnly:
|
||||
return entry != null && entry.owned;
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.specificSonOnly:
|
||||
return HasEntryStorySon(entry, itemSO.associatedStorySonId)
|
||||
|| HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId);
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon:
|
||||
return (entry != null && entry.owned)
|
||||
|| HasEntryStorySon(entry, itemSO.associatedStorySonId)
|
||||
|| HasStorySonUnlocked(itemSO.associatedStoryPassage, itemSO.associatedStorySonId);
|
||||
|
||||
case storeItemSO.StoryPassageGrantMode.allSons:
|
||||
return (entry != null && entry.owned) || AreAllStorySonsUnlocked(itemSO.associatedStoryPassage);
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasEntryStorySon(StoreOwnershipEntry entry, int sonId)
|
||||
{
|
||||
if (entry == null || entry.unlockedStorySonIds == null || sonId < 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < entry.unlockedStorySonIds.Count; i++)
|
||||
{
|
||||
if (entry.unlockedStorySonIds[i] == sonId)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool HasStorySonUnlocked(notebook_faterType story, int sonId)
|
||||
{
|
||||
int index = FindStorySonIndex(story, sonId);
|
||||
return index >= 0 && story.sonList[index].son_isUnlocked;
|
||||
}
|
||||
|
||||
private static bool AreAllStorySonsUnlocked(notebook_faterType story)
|
||||
{
|
||||
if (story == null || story.sonList == null || story.sonList.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son == null || !son.son_isUnlocked)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int FindStorySonIndex(notebook_faterType story, int sonId)
|
||||
{
|
||||
if (story == null || story.sonList == null || sonId < 0)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son != null && son.son_id == sonId)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static bool RequiresSpecificStorySon(storeItemSO.StoryPassageGrantMode mode)
|
||||
{
|
||||
return mode == storeItemSO.StoryPassageGrantMode.specificSonOnly
|
||||
|| mode == storeItemSO.StoryPassageGrantMode.fatherAndSpecificSon;
|
||||
}
|
||||
|
||||
private static void AddStorySonId(List<int> sonIds, int sonId)
|
||||
{
|
||||
if (sonIds == null || sonId < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < sonIds.Count; i++)
|
||||
{
|
||||
if (sonIds[i] == sonId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sonIds.Add(sonId);
|
||||
}
|
||||
|
||||
private static void AddAllStorySonIds(notebook_faterType story, List<int> sonIds)
|
||||
{
|
||||
if (story == null || story.sonList == null || sonIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < story.sonList.Count; i++)
|
||||
{
|
||||
var son = story.sonList[i];
|
||||
if (son == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddStorySonId(sonIds, son.son_id);
|
||||
}
|
||||
}
|
||||
|
||||
private StoreOwnershipEntry GetOrCreateEntry(int storeItemId)
|
||||
{
|
||||
StoreOwnershipEntry entry;
|
||||
if (entriesByItemId.TryGetValue(storeItemId, out entry))
|
||||
{
|
||||
return entry;
|
||||
}
|
||||
|
||||
entry = new StoreOwnershipEntry
|
||||
{
|
||||
storeItemId = storeItemId,
|
||||
owned = false,
|
||||
unlockedStorySonIds = new List<int>()
|
||||
};
|
||||
entriesByItemId[storeItemId] = entry;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static StoreOwnershipEntry CloneEntry(StoreOwnershipEntry source)
|
||||
{
|
||||
return new StoreOwnershipEntry
|
||||
{
|
||||
storeItemId = source.storeItemId,
|
||||
owned = source.owned,
|
||||
unlockedStorySonIds = source.unlockedStorySonIds != null
|
||||
? new List<int>(source.unlockedStorySonIds)
|
||||
: new List<int>()
|
||||
};
|
||||
}
|
||||
|
||||
private static void MarkDirty(UnityEngine.Object target)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (target != null)
|
||||
{
|
||||
EditorUtility.SetDirty(target);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 873f2d3326b8e9c42b3ad9757ef2b4b2
|
||||
@@ -0,0 +1,25 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipEntry
|
||||
{
|
||||
public int storeItemId;
|
||||
public bool owned;
|
||||
public List<int> unlockedStorySonIds = new List<int>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipPayload
|
||||
{
|
||||
public int version;
|
||||
public long lastUpdatedUtcTicks;
|
||||
public List<StoreOwnershipEntry> entries = new List<StoreOwnershipEntry>();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
public class StoreOwnershipEnvelope
|
||||
{
|
||||
public string payload;
|
||||
public string signature;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df11f9fb00f4cb949ad6556e341680bc
|
||||
@@ -0,0 +1,202 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
public static class StoreOwnershipStorage
|
||||
{
|
||||
private const string SecretSeed = "ban_total.store_ownership.v1";
|
||||
private const string VaultDirectoryName = ".cache_bridge";
|
||||
private const string MainFileName = ".own.dat";
|
||||
private const string BackupFileName = ".own.bak";
|
||||
private const string TempFileName = ".own.tmp";
|
||||
|
||||
private static string VaultDirectoryPath
|
||||
{
|
||||
get { return Path.Combine(Application.persistentDataPath, VaultDirectoryName); }
|
||||
}
|
||||
|
||||
private static string MainFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, MainFileName); }
|
||||
}
|
||||
|
||||
private static string BackupFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, BackupFileName); }
|
||||
}
|
||||
|
||||
private static string TempFilePath
|
||||
{
|
||||
get { return Path.Combine(VaultDirectoryPath, TempFileName); }
|
||||
}
|
||||
|
||||
public static bool TryLoad(out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
|
||||
if (TryReadPayload(MainFilePath, out payload))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryReadPayload(BackupFilePath, out payload))
|
||||
{
|
||||
TrySave(payload);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TrySave(StoreOwnershipPayload payload)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(VaultDirectoryPath);
|
||||
TryHidePath(VaultDirectoryPath);
|
||||
|
||||
var envelopeJson = BuildEnvelopeJson(payload);
|
||||
File.WriteAllText(TempFilePath, envelopeJson, Encoding.UTF8);
|
||||
TryHidePath(TempFilePath);
|
||||
|
||||
if (File.Exists(MainFilePath))
|
||||
{
|
||||
File.Copy(MainFilePath, BackupFilePath, true);
|
||||
TryHidePath(BackupFilePath);
|
||||
}
|
||||
|
||||
File.Copy(TempFilePath, MainFilePath, true);
|
||||
TryHidePath(MainFilePath);
|
||||
File.Delete(TempFilePath);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[StoreOwnershipStorage] Save failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static StoreOwnershipPayload CreateDefaultPayload()
|
||||
{
|
||||
return new StoreOwnershipPayload
|
||||
{
|
||||
version = 1,
|
||||
lastUpdatedUtcTicks = DateTime.UtcNow.Ticks,
|
||||
entries = new System.Collections.Generic.List<StoreOwnershipEntry>()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryReadPayload(string path, out StoreOwnershipPayload payload)
|
||||
{
|
||||
payload = CreateDefaultPayload();
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var envelopeJson = File.ReadAllText(path, Encoding.UTF8);
|
||||
var envelope = JsonUtility.FromJson<StoreOwnershipEnvelope>(envelopeJson);
|
||||
if (envelope == null || string.IsNullOrEmpty(envelope.payload) || string.IsNullOrEmpty(envelope.signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expectedSignature = ComputeSignature(envelope.payload);
|
||||
if (!string.Equals(expectedSignature, envelope.signature, StringComparison.Ordinal))
|
||||
{
|
||||
Debug.LogWarning("[StoreOwnershipStorage] Save signature mismatch. Possible tampering detected.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var encryptedBytes = Convert.FromBase64String(envelope.payload);
|
||||
var plainBytes = XorTransform(encryptedBytes, BuildKeyBytes());
|
||||
var payloadJson = Encoding.UTF8.GetString(plainBytes);
|
||||
var loadedPayload = JsonUtility.FromJson<StoreOwnershipPayload>(payloadJson);
|
||||
if (loadedPayload == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
payload = loadedPayload;
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[StoreOwnershipStorage] Load failed from '{path}': {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildEnvelopeJson(StoreOwnershipPayload payload)
|
||||
{
|
||||
payload.lastUpdatedUtcTicks = DateTime.UtcNow.Ticks;
|
||||
var payloadJson = JsonUtility.ToJson(payload, false);
|
||||
var plainBytes = Encoding.UTF8.GetBytes(payloadJson);
|
||||
var encryptedBytes = XorTransform(plainBytes, BuildKeyBytes());
|
||||
var payloadBase64 = Convert.ToBase64String(encryptedBytes);
|
||||
|
||||
var envelope = new StoreOwnershipEnvelope
|
||||
{
|
||||
payload = payloadBase64,
|
||||
signature = ComputeSignature(payloadBase64)
|
||||
};
|
||||
|
||||
return JsonUtility.ToJson(envelope, false);
|
||||
}
|
||||
|
||||
private static string ComputeSignature(string payloadBase64)
|
||||
{
|
||||
var signText = payloadBase64 + "|" + Application.identifier + "|" + SecretSeed + "|" + SystemInfo.deviceUniqueIdentifier;
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(signText);
|
||||
var hash = sha.ComputeHash(bytes);
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] BuildKeyBytes()
|
||||
{
|
||||
using (var sha = SHA256.Create())
|
||||
{
|
||||
var seed = Application.identifier + "|" + SystemInfo.deviceUniqueIdentifier + "|" + SecretSeed;
|
||||
return sha.ComputeHash(Encoding.UTF8.GetBytes(seed));
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] XorTransform(byte[] source, byte[] key)
|
||||
{
|
||||
var result = new byte[source.Length];
|
||||
for (int i = 0; i < source.Length; i++)
|
||||
{
|
||||
result[i] = (byte)(source[i] ^ key[i % key.Length]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void TryHidePath(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Application.platform != RuntimePlatform.WindowsEditor && Application.platform != RuntimePlatform.WindowsPlayer)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.Hidden) == 0)
|
||||
{
|
||||
File.SetAttributes(path, attributes | FileAttributes.Hidden);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f902609758920443b9df12a43213dd6
|
||||
Reference in New Issue
Block a user