365 lines
14 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|