390 lines
15 KiB
C#
390 lines
15 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Linq;
|
|
using ProjectQuint.Core.Gauge;
|
|
using UnityEngine;
|
|
|
|
namespace ProjectQuint.Library
|
|
{
|
|
public sealed class AeryCourseIndex
|
|
{
|
|
private readonly List<AeryCourseDefinition> _courses;
|
|
|
|
private AeryCourseIndex(
|
|
string providerId,
|
|
string providerDisplayName,
|
|
string tableName,
|
|
string symbol,
|
|
string lastUpdate,
|
|
List<AeryCourseDefinition> courses)
|
|
{
|
|
ProviderId = NormalizeProviderId(providerId);
|
|
ProviderDisplayName = string.IsNullOrWhiteSpace(providerDisplayName)
|
|
? tableName ?? "BMS TABLE"
|
|
: providerDisplayName.Trim();
|
|
TableName = tableName ?? string.Empty;
|
|
Symbol = symbol ?? string.Empty;
|
|
LastUpdate = lastUpdate ?? string.Empty;
|
|
_courses = courses ?? new List<AeryCourseDefinition>();
|
|
foreach (var course in _courses)
|
|
{
|
|
course.ProviderId = ProviderId;
|
|
course.ProviderDisplayName = ProviderDisplayName;
|
|
course.Rules = BmsCourseRules.Analyze(course.constraint);
|
|
}
|
|
}
|
|
|
|
public string ProviderId { get; }
|
|
public string ProviderDisplayName { get; }
|
|
public string TableName { get; }
|
|
public string Symbol { get; }
|
|
public string LastUpdate { get; }
|
|
public ExternalDataOrigin Origin { get; private set; }
|
|
public IReadOnlyList<AeryCourseDefinition> Courses => _courses;
|
|
|
|
public void ResolveSongs(IReadOnlyList<SongLibraryEntry> songs)
|
|
{
|
|
var byHash = (songs ?? Array.Empty<SongLibraryEntry>())
|
|
.SelectMany(song => new[]
|
|
{
|
|
new { Hash = song?.Metadata?.Md5, Song = song },
|
|
new { Hash = song?.Metadata?.Sha256, Song = song },
|
|
})
|
|
.Where(item => !string.IsNullOrWhiteSpace(item.Hash) && item.Song != null)
|
|
.GroupBy(item => item.Hash.Trim(), StringComparer.OrdinalIgnoreCase)
|
|
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
|
|
|
foreach (var course in _courses)
|
|
{
|
|
var hashes = course.StageHashes;
|
|
course.Stages = new SongLibraryEntry[hashes.Length];
|
|
for (var stage = 0; stage < hashes.Length; stage++)
|
|
{
|
|
if (byHash.TryGetValue(hashes[stage] ?? string.Empty, out var match))
|
|
course.Stages[stage] = match.Song;
|
|
}
|
|
course.EvaluateStageCompatibility();
|
|
course.Record = AeryCourseRecordStore.Load(course);
|
|
}
|
|
}
|
|
|
|
internal static ExternalDataResult<AeryCourseIndex> LoadTableLocal(
|
|
string providerId,
|
|
string providerDisplayName,
|
|
string headerCachePath)
|
|
{
|
|
var result = ExternalJsonDataSource.LoadLocal(
|
|
$"{providerDisplayName} courses",
|
|
headerCachePath,
|
|
string.Empty,
|
|
json => ParseValidated(providerId, providerDisplayName, json));
|
|
result.Data ??= Empty(providerId, providerDisplayName);
|
|
result.Data.Origin = result.Origin;
|
|
return result;
|
|
}
|
|
|
|
internal static AeryCourseIndex Parse(string json)
|
|
=> Parse("aery", "AERY", json);
|
|
|
|
internal static AeryCourseIndex Parse(string providerId, string providerDisplayName, string json)
|
|
{
|
|
try
|
|
{
|
|
return ParseValidated(providerId, providerDisplayName, json) ?? Empty(providerId, providerDisplayName);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Debug.LogWarning($"[ProjectQuint] Failed to parse BMSTable course data for '{providerId}': {exception.Message}");
|
|
return Empty(providerId, providerDisplayName);
|
|
}
|
|
}
|
|
|
|
private static AeryCourseIndex ParseValidated(string providerId, string providerDisplayName, string json)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(json))
|
|
return null;
|
|
|
|
var metadata = JsonUtility.FromJson<AeryCourseHeaderMetadata>(json) ?? new AeryCourseHeaderMetadata();
|
|
var courseArray = ExtractArrayProperty(json, "course");
|
|
var courses = ExtractCourseObjects(courseArray)
|
|
.Select(item =>
|
|
{
|
|
var course = JsonUtility.FromJson<AeryCourseDefinition>(item.Json);
|
|
if (course != null)
|
|
course.GroupIndex = item.GroupIndex;
|
|
return course;
|
|
})
|
|
.Where(course => course != null && course.StageHashes.Length > 0)
|
|
.ToList();
|
|
return courses.Count > 0
|
|
? new AeryCourseIndex(
|
|
providerId,
|
|
string.IsNullOrWhiteSpace(metadata.name) ? providerDisplayName : metadata.name,
|
|
metadata.name,
|
|
metadata.symbol,
|
|
metadata.last_update,
|
|
courses)
|
|
: null;
|
|
}
|
|
|
|
private static AeryCourseIndex Empty(string providerId, string providerDisplayName) =>
|
|
new AeryCourseIndex(
|
|
providerId,
|
|
providerDisplayName,
|
|
providerDisplayName,
|
|
string.Empty,
|
|
string.Empty,
|
|
new List<AeryCourseDefinition>());
|
|
|
|
private static string ExtractArrayProperty(string json, string propertyName)
|
|
{
|
|
var propertyIndex = json.IndexOf($"\"{propertyName}\"", StringComparison.Ordinal);
|
|
if (propertyIndex < 0)
|
|
throw new FormatException($"JSON property '{propertyName}' was not found.");
|
|
|
|
var start = json.IndexOf('[', propertyIndex);
|
|
if (start < 0)
|
|
throw new FormatException($"JSON property '{propertyName}' is not an array.");
|
|
var end = FindMatchingContainerEnd(json, start, '[', ']');
|
|
return json.Substring(start, end - start + 1);
|
|
}
|
|
|
|
private static IEnumerable<CourseJsonItem> ExtractCourseObjects(string courseArray)
|
|
{
|
|
var arrayDepth = 0;
|
|
var groupIndex = -1;
|
|
var inString = false;
|
|
var escaped = false;
|
|
for (var index = 0; index < courseArray.Length; index++)
|
|
{
|
|
var character = courseArray[index];
|
|
if (inString)
|
|
{
|
|
if (escaped)
|
|
escaped = false;
|
|
else if (character == '\\')
|
|
escaped = true;
|
|
else if (character == '"')
|
|
inString = false;
|
|
continue;
|
|
}
|
|
if (character == '"')
|
|
{
|
|
inString = true;
|
|
continue;
|
|
}
|
|
if (character == '[')
|
|
{
|
|
arrayDepth++;
|
|
if (arrayDepth == 2)
|
|
groupIndex++;
|
|
continue;
|
|
}
|
|
if (character == ']')
|
|
{
|
|
arrayDepth--;
|
|
continue;
|
|
}
|
|
if (character != '{')
|
|
continue;
|
|
var end = FindMatchingContainerEnd(courseArray, index, '{', '}');
|
|
yield return new CourseJsonItem(
|
|
Math.Max(0, groupIndex),
|
|
courseArray.Substring(index, end - index + 1));
|
|
index = end;
|
|
}
|
|
}
|
|
|
|
private static int FindMatchingContainerEnd(string json, int start, char open, char close)
|
|
{
|
|
var depth = 0;
|
|
var inString = false;
|
|
var escaped = false;
|
|
for (var index = start; index < json.Length; index++)
|
|
{
|
|
var character = json[index];
|
|
if (inString)
|
|
{
|
|
if (escaped)
|
|
escaped = false;
|
|
else if (character == '\\')
|
|
escaped = true;
|
|
else if (character == '"')
|
|
inString = false;
|
|
continue;
|
|
}
|
|
|
|
if (character == '"')
|
|
inString = true;
|
|
else if (character == open)
|
|
depth++;
|
|
else if (character == close && --depth == 0)
|
|
return index;
|
|
}
|
|
throw new FormatException($"Unterminated JSON container '{open}'.");
|
|
}
|
|
|
|
private static string NormalizeProviderId(string providerId)
|
|
{
|
|
var normalized = (providerId ?? string.Empty).Trim().ToLowerInvariant();
|
|
return string.IsNullOrWhiteSpace(normalized) ? "bmstable" : normalized;
|
|
}
|
|
|
|
[Serializable]
|
|
private sealed class AeryCourseHeaderMetadata
|
|
{
|
|
public string name;
|
|
public string symbol;
|
|
public string last_update;
|
|
}
|
|
|
|
private readonly struct CourseJsonItem
|
|
{
|
|
public CourseJsonItem(int groupIndex, string json)
|
|
{
|
|
GroupIndex = groupIndex;
|
|
Json = json;
|
|
}
|
|
|
|
public int GroupIndex { get; }
|
|
public string Json { get; }
|
|
}
|
|
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AeryCourseDefinition
|
|
{
|
|
public string name;
|
|
public string[] constraint;
|
|
public AeryCourseTrophy[] trophy;
|
|
public string[] md5;
|
|
public string[] sha256;
|
|
|
|
[NonSerialized] public string ProviderId = "aery";
|
|
[NonSerialized] public string ProviderDisplayName = "AERY";
|
|
[NonSerialized] public int GroupIndex;
|
|
[NonSerialized] public BmsCourseRules Rules = BmsCourseRules.Analyze(Array.Empty<string>());
|
|
[NonSerialized] public CourseGaugeProfile ResolvedGaugeProfile = CourseGaugeProfile.Lr2;
|
|
[NonSerialized] public int ResolvedKeyCount;
|
|
[NonSerialized] public bool ResolvedHasScratch;
|
|
[NonSerialized] public string StageCompatibilityError = string.Empty;
|
|
[NonSerialized] public SongLibraryEntry[] Stages = Array.Empty<SongLibraryEntry>();
|
|
[NonSerialized] public AeryCourseRecord Record = new AeryCourseRecord();
|
|
|
|
public string[] StageHashes => md5 != null && md5.Length > 0
|
|
? md5
|
|
: sha256 ?? Array.Empty<string>();
|
|
public int StageCount => StageHashes.Length;
|
|
public int MissingStageCount => Stages == null || Stages.Length != StageCount
|
|
? StageCount
|
|
: Stages.Count(stage => stage == null);
|
|
public bool IsAvailable => StageCount > 0 && MissingStageCount == 0;
|
|
public bool AllowsMirror => Rules?.AllowsMirror == true;
|
|
public bool IsPlayable => IsAvailable && Rules?.IsConstraintSupported == true &&
|
|
string.IsNullOrWhiteSpace(StageCompatibilityError);
|
|
public string UnavailableReason
|
|
{
|
|
get
|
|
{
|
|
if (Rules?.IsConstraintSupported == false)
|
|
return Rules.UnsupportedReason;
|
|
if (!string.IsNullOrWhiteSpace(StageCompatibilityError))
|
|
return StageCompatibilityError;
|
|
return MissingStageCount > 0
|
|
? $"{MissingStageCount} LOCAL CHART(S) MISSING"
|
|
: StageCount == 0 ? "COURSE HAS NO STAGES" : string.Empty;
|
|
}
|
|
}
|
|
|
|
internal void EvaluateStageCompatibility()
|
|
{
|
|
StageCompatibilityError = string.Empty;
|
|
ResolvedKeyCount = 0;
|
|
ResolvedHasScratch = false;
|
|
if (!IsAvailable)
|
|
return;
|
|
|
|
var keyCounts = Stages
|
|
.Select(stage => stage?.EffectiveKeyCount ?? 0)
|
|
.Distinct()
|
|
.ToArray();
|
|
if (keyCounts.Length != 1 || keyCounts[0] < 4 || keyCounts[0] > 7)
|
|
{
|
|
StageCompatibilityError = "MIXED OR UNSUPPORTED KEY MODES";
|
|
return;
|
|
}
|
|
ResolvedKeyCount = keyCounts[0];
|
|
// Scratch object presence can legitimately vary between stages of the
|
|
// same key mode. The stage loader configures the scratch lane per chart.
|
|
ResolvedHasScratch = Stages.Any(stage => stage?.Metadata?.HasScratch == true);
|
|
if (Rules.GaugeRule == BmsCourseGaugeRule.FiveKeys && ResolvedKeyCount != 5)
|
|
{
|
|
StageCompatibilityError = $"5KEY GAUGE CANNOT RUN A {ResolvedKeyCount}KEY COURSE";
|
|
return;
|
|
}
|
|
if (Rules.GaugeRule == BmsCourseGaugeRule.SevenKeys && ResolvedKeyCount != 7)
|
|
{
|
|
StageCompatibilityError = $"7KEY GAUGE CANNOT RUN A {ResolvedKeyCount}KEY COURSE";
|
|
return;
|
|
}
|
|
ResolvedGaugeProfile = Rules.ResolveGaugeProfile(ResolvedKeyCount);
|
|
}
|
|
|
|
public string DisplayName
|
|
{
|
|
get
|
|
{
|
|
const string marker = "段位認定 ";
|
|
var source = name ?? string.Empty;
|
|
var start = source.IndexOf(marker, StringComparison.Ordinal);
|
|
if (start >= 0)
|
|
source = source.Substring(start + marker.Length);
|
|
var version = source.IndexOf(" /", StringComparison.Ordinal);
|
|
return (version >= 0 ? source.Substring(0, version) : source).Trim();
|
|
}
|
|
}
|
|
|
|
public string Version
|
|
{
|
|
get
|
|
{
|
|
var source = name ?? string.Empty;
|
|
var marker = source.LastIndexOf("ver.", StringComparison.OrdinalIgnoreCase);
|
|
return marker >= 0 ? source.Substring(marker).Trim() : string.Empty;
|
|
}
|
|
}
|
|
|
|
public AeryCourseTrophy ResolveTrophy(double missRate, double scoreRate)
|
|
{
|
|
AeryCourseTrophy resolved = null;
|
|
foreach (var candidate in trophy ?? Array.Empty<AeryCourseTrophy>())
|
|
{
|
|
if (missRate <= candidate.missrate + 0.000001 &&
|
|
scoreRate + 0.000001 >= candidate.scorerate)
|
|
resolved = candidate;
|
|
}
|
|
return resolved;
|
|
}
|
|
}
|
|
|
|
[Serializable]
|
|
public sealed class AeryCourseTrophy
|
|
{
|
|
public string name;
|
|
public float missrate;
|
|
public float scorerate;
|
|
|
|
public int Rank => string.Equals(name, "goldmedal", StringComparison.OrdinalIgnoreCase) ? 3 :
|
|
string.Equals(name, "silvermedal", StringComparison.OrdinalIgnoreCase) ? 2 :
|
|
string.Equals(name, "bronzemedal", StringComparison.OrdinalIgnoreCase) ? 1 : 0;
|
|
public string DisplayName => Rank switch
|
|
{
|
|
3 => "GOLD",
|
|
2 => "SILVER",
|
|
1 => "BRONZE",
|
|
_ => string.Empty,
|
|
};
|
|
}
|
|
}
|