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

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,
};
}
}