加入用户最近100场战绩记录并实现展示

This commit is contained in:
FloatGaming
2026-03-16 23:18:58 +08:00
parent ec42f2e34b
commit f5c6f143c0
106 changed files with 12120 additions and 642 deletions
@@ -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