Files
ProjectQuint/Assets/ProjectQuint/Runtime/Unity/Library/BmsDifficultyTableRegistry.cs
T

365 lines
14 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using ProjectQuint.Persistence;
namespace ProjectQuint.Library
{
[Serializable]
public sealed class BmsDifficultyTableDefinition
{
public string Id = string.Empty;
public string DisplayName = string.Empty;
public string HeaderUrl = string.Empty;
public long AddedUtcTicks;
public List<string> LegacyProviderIds = new List<string>();
public bool IsConfigured => !string.IsNullOrWhiteSpace(HeaderUrl);
}
[Serializable]
internal sealed class BmsDifficultyTableRegistryDocument
{
public const int CurrentSchemaVersion = 1;
public int SchemaVersion = CurrentSchemaVersion;
public string ActiveTableId = string.Empty;
public List<BmsDifficultyTableDefinition> Tables = new List<BmsDifficultyTableDefinition>();
}
/// <summary>
/// Global difficulty-table definition and cache registry. Each pack stores only a table ID
/// reference; entries still match indexed charts by MD5/SHA-256.
/// </summary>
internal sealed class BmsDifficultyTableRegistry
{
private readonly AtomicJsonFile<BmsDifficultyTableRegistryDocument> _file;
private readonly string _cacheRoot;
private readonly List<BmsDifficultyTableDefinition> _tables =
new List<BmsDifficultyTableDefinition>();
public BmsDifficultyTableRegistry(
string registryPath = null,
string cacheRoot = null,
Action<string> logWarning = null)
{
var path = registryPath ?? PersistencePaths.DifficultyTableRegistry;
_cacheRoot = cacheRoot ?? PersistencePaths.DifficultyTableRoot;
_file = new AtomicJsonFile<BmsDifficultyTableRegistryDocument>(
path,
document => document != null &&
document.SchemaVersion == BmsDifficultyTableRegistryDocument.CurrentSchemaVersion &&
document.Tables != null,
logWarning);
Load();
EnsurePresets();
}
public IReadOnlyList<BmsDifficultyTableDefinition> Tables => _tables;
public string ActiveTableId { get; private set; } = string.Empty;
public BmsDifficultyTableDefinition ActiveTable => Find(ActiveTableId);
public BmsDifficultyTableDefinition Register(
string headerUrl,
string displayName = null,
string preferredId = null,
bool activate = true)
{
var url = NormalizeUrl(headerUrl);
var preset = FindPresetByUrl(url);
var id = NormalizeId(!string.IsNullOrWhiteSpace(preferredId)
? preferredId
: preset?.Id ?? BuildStableId(url));
var existing = FindByUrl(url) ?? Find(id);
if (existing == null)
{
existing = new BmsDifficultyTableDefinition
{
Id = id,
DisplayName = ResolveDisplayName(displayName, preset, id),
HeaderUrl = url,
AddedUtcTicks = DateTime.UtcNow.Ticks,
};
_tables.Add(existing);
}
else
{
existing.HeaderUrl = url;
if (!string.IsNullOrWhiteSpace(displayName))
existing.DisplayName = displayName.Trim();
else if (string.IsNullOrWhiteSpace(existing.DisplayName))
existing.DisplayName = ResolveDisplayName(null, preset, existing.Id);
}
if (activate)
ActiveTableId = existing.Id;
Save();
return existing;
}
public BmsDifficultyTableDefinition RegisterPreset(int presetIndex, bool activate = true)
{
if (presetIndex < 0 || presetIndex >= BmsDifficultyTablePresets.All.Length)
throw new ArgumentOutOfRangeException(nameof(presetIndex));
var preset = BmsDifficultyTablePresets.All[presetIndex];
return Register(preset.HeaderUrl, preset.DisplayName, preset.Id, activate);
}
public void Select(string tableId)
{
var table = Find(tableId) ?? throw new ArgumentException("Unknown difficulty table.", nameof(tableId));
ActiveTableId = table.Id;
Save();
}
public bool Remove(string tableId)
{
var table = Find(tableId);
if (table == null)
return false;
_tables.Remove(table);
if (string.Equals(ActiveTableId, table.Id, StringComparison.OrdinalIgnoreCase))
ActiveTableId = string.Empty;
Save();
return true;
}
public void ClearSelection()
{
if (string.IsNullOrWhiteSpace(ActiveTableId))
return;
ActiveTableId = string.Empty;
Save();
}
public BmsDifficultyTableDefinition Find(string tableId)
{
if (string.IsNullOrWhiteSpace(tableId))
return null;
return _tables.Find(table =>
string.Equals(table.Id, tableId.Trim(), StringComparison.OrdinalIgnoreCase));
}
public BmsDifficultyTableDefinition FindByUrl(string headerUrl)
{
if (string.IsNullOrWhiteSpace(headerUrl))
return null;
var normalized = NormalizeUrl(headerUrl);
return _tables.Find(table =>
string.Equals(table.HeaderUrl, normalized, StringComparison.OrdinalIgnoreCase));
}
public int MigrateLegacyPackTables(IEnumerable<BmsPackDefinition> packs, string preferredPackId)
{
var candidates = (packs ?? Array.Empty<BmsPackDefinition>())
.Where(pack => pack != null && !string.IsNullOrWhiteSpace(pack.DifficultyTableUrl))
.OrderBy(pack => string.Equals(pack.Id, preferredPackId, StringComparison.OrdinalIgnoreCase) ? 0 : 1)
.ToList();
var migrated = 0;
BmsDifficultyTableDefinition preferred = null;
foreach (var pack in candidates)
{
var before = FindByUrl(pack.DifficultyTableUrl);
var table = Register(pack.DifficultyTableUrl, activate: false);
PromoteLegacyCache(pack.Id, table);
table.LegacyProviderIds ??= new List<string>();
if (!string.Equals(pack.Id, table.Id, StringComparison.OrdinalIgnoreCase) &&
!table.LegacyProviderIds.Any(id =>
string.Equals(id, pack.Id, StringComparison.OrdinalIgnoreCase)))
table.LegacyProviderIds.Add(pack.Id);
if (before == null)
migrated++;
if (preferred == null)
preferred = table;
}
if (preferred != null)
{
ActiveTableId = preferred.Id;
Save();
}
return migrated;
}
public IReadOnlyDictionary<string, BmsDifficultyTableDefinition> BuildLegacyProviderMap()
{
var result = new Dictionary<string, BmsDifficultyTableDefinition>(StringComparer.OrdinalIgnoreCase);
foreach (var table in _tables)
{
foreach (var legacyId in table.LegacyProviderIds ?? new List<string>())
{
if (!string.IsNullOrWhiteSpace(legacyId))
result[legacyId.Trim()] = table;
}
}
return result;
}
public void UpdateDisplayName(string tableId, string displayName)
{
var table = Find(tableId);
if (table == null || string.IsNullOrWhiteSpace(displayName) ||
string.Equals(table.DisplayName, displayName.Trim(), StringComparison.Ordinal))
return;
table.DisplayName = displayName.Trim();
Save();
}
public string HeaderCachePathFor(BmsDifficultyTableDefinition table) =>
CachePathFor(table, "header.json");
public string DataCachePathFor(BmsDifficultyTableDefinition table) =>
CachePathFor(table, "data.json");
private string CachePathFor(BmsDifficultyTableDefinition table, string fileName)
{
if (table == null || string.IsNullOrWhiteSpace(table.Id))
throw new ArgumentNullException(nameof(table));
return Path.Combine(_cacheRoot, NormalizeId(table.Id), fileName);
}
private void EnsurePresets()
{
var changed = false;
foreach (var preset in BmsDifficultyTablePresets.All)
{
if (FindByUrl(preset.HeaderUrl) != null)
continue;
_tables.Add(new BmsDifficultyTableDefinition
{
Id = NormalizeId(preset.Id),
DisplayName = preset.DisplayName,
HeaderUrl = NormalizeUrl(preset.HeaderUrl),
AddedUtcTicks = DateTime.UtcNow.Ticks,
});
changed = true;
}
if (changed)
Save();
}
private void PromoteLegacyCache(string packId, BmsDifficultyTableDefinition table)
{
if (string.IsNullOrWhiteSpace(packId) || table == null)
return;
var legacyRoot = Path.Combine(_cacheRoot, packId);
if (!Directory.Exists(legacyRoot))
return;
var targetRoot = Path.Combine(_cacheRoot, NormalizeId(table.Id));
Directory.CreateDirectory(targetRoot);
CopyIfMissing(
Path.Combine(legacyRoot, "header.json"),
Path.Combine(targetRoot, "header.json"));
CopyIfMissing(
Path.Combine(legacyRoot, "data.json"),
Path.Combine(targetRoot, "data.json"));
}
private static void CopyIfMissing(string sourcePath, string destinationPath)
{
if (!File.Exists(sourcePath) || File.Exists(destinationPath))
return;
File.Copy(sourcePath, destinationPath, overwrite: false);
}
private void Load()
{
_tables.Clear();
if (!_file.TryLoad(out var document, out _))
return;
foreach (var table in document.Tables)
{
if (table == null || string.IsNullOrWhiteSpace(table.Id) ||
string.IsNullOrWhiteSpace(table.HeaderUrl))
continue;
try
{
table.Id = NormalizeId(table.Id);
table.HeaderUrl = NormalizeUrl(table.HeaderUrl);
}
catch
{
continue;
}
table.DisplayName = string.IsNullOrWhiteSpace(table.DisplayName)
? table.Id
: table.DisplayName.Trim();
table.LegacyProviderIds ??= new List<string>();
if (_tables.All(existing =>
!string.Equals(existing.Id, table.Id, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(existing.HeaderUrl, table.HeaderUrl, StringComparison.OrdinalIgnoreCase)))
_tables.Add(table);
}
ActiveTableId = document.ActiveTableId ?? string.Empty;
if (Find(ActiveTableId) == null)
ActiveTableId = string.Empty;
}
private void Save()
{
_file.Save(new BmsDifficultyTableRegistryDocument
{
ActiveTableId = ActiveTableId,
Tables = new List<BmsDifficultyTableDefinition>(_tables),
});
}
private static BmsDifficultyTablePreset? FindPresetByUrl(string url)
{
foreach (var preset in BmsDifficultyTablePresets.All)
if (string.Equals(preset.HeaderUrl, url, StringComparison.OrdinalIgnoreCase))
return preset;
return null;
}
private static string ResolveDisplayName(
string displayName,
BmsDifficultyTablePreset? preset,
string fallback)
{
if (!string.IsNullOrWhiteSpace(displayName))
return displayName.Trim();
if (preset.HasValue)
return preset.Value.DisplayName;
return string.IsNullOrWhiteSpace(fallback) ? "BMS TABLE" : fallback;
}
private static string NormalizeUrl(string headerUrl)
{
var value = (headerUrl ?? string.Empty).Trim();
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) ||
uri.Scheme != Uri.UriSchemeHttps)
throw new ArgumentException("Difficulty table URL must use HTTPS.", nameof(headerUrl));
return uri.AbsoluteUri;
}
private static string NormalizeId(string tableId)
{
var value = (tableId ?? string.Empty).Trim().ToLowerInvariant();
if (value.Length == 0)
throw new ArgumentException("A difficulty-table ID is required.", nameof(tableId));
var builder = new StringBuilder(value.Length);
foreach (var character in value)
{
if (char.IsLetterOrDigit(character) || character == '-' || character == '_')
builder.Append(character);
else
builder.Append('-');
}
return builder.ToString().Trim('-');
}
private static string BuildStableId(string url)
{
using var sha = SHA256.Create();
var hash = sha.ComputeHash(Encoding.UTF8.GetBytes(url.ToUpperInvariant()));
var builder = new StringBuilder("table-");
for (var index = 0; index < 10; index++)
builder.Append(hash[index].ToString("x2"));
return builder.ToString();
}
}
}