프로젝트 소스 공개

This commit is contained in:
2026-09-06 17:11:40 +09:00
commit 0f0431de5a
676 changed files with 106118 additions and 0 deletions
@@ -0,0 +1,198 @@
using System;
using System.Collections.Generic;
namespace ProjectQuint.Core.Progression
{
public readonly struct CollectionGroupSummary
{
public CollectionGroupSummary(
string id,
string displayName,
int played,
int normalClear,
int hardClear,
int failed,
int fullCombo,
int allPure)
{
Id = id ?? string.Empty;
DisplayName = string.IsNullOrWhiteSpace(displayName) ? "UNKNOWN" : displayName;
Played = Math.Max(0, played);
NormalClear = Math.Max(0, normalClear);
HardClear = Math.Max(0, hardClear);
Failed = Math.Max(0, failed);
FullCombo = Math.Max(0, fullCombo);
AllPure = Math.Max(0, allPure);
}
public string Id { get; }
public string DisplayName { get; }
public int Played { get; }
public int NormalClear { get; }
public int HardClear { get; }
public int Failed { get; }
public int FullCombo { get; }
public int AllPure { get; }
public int Cleared => NormalClear + HardClear;
public double ClearRate => Played <= 0 ? 0.0 : Cleared * 100.0 / Played;
}
public sealed class ProfileCollectionSnapshot
{
public static readonly ProfileCollectionSnapshot Empty = new ProfileCollectionSnapshot(
default,
Array.Empty<CollectionGroupSummary>(),
Array.Empty<CollectionGroupSummary>(),
Array.Empty<CollectionGroupSummary>());
public ProfileCollectionSnapshot(
CollectionGroupSummary total,
IReadOnlyList<CollectionGroupSummary> packs,
IReadOnlyList<CollectionGroupSummary> keyModes,
IReadOnlyList<CollectionGroupSummary> difficulties)
{
Total = total;
Packs = packs ?? Array.Empty<CollectionGroupSummary>();
KeyModes = keyModes ?? Array.Empty<CollectionGroupSummary>();
Difficulties = difficulties ?? Array.Empty<CollectionGroupSummary>();
}
public CollectionGroupSummary Total { get; }
public IReadOnlyList<CollectionGroupSummary> Packs { get; }
public IReadOnlyList<CollectionGroupSummary> KeyModes { get; }
public IReadOnlyList<CollectionGroupSummary> Difficulties { get; }
}
public readonly struct CourseCollectionEntry
{
public CourseCollectionEntry(
string courseId,
string providerDisplayName,
string courseName,
bool cleared,
int playCount,
int clearCount,
int bestStageReached,
double bestAccuracyPercent,
int bestTrophyRank,
string bestTrophyName)
{
CourseId = courseId ?? string.Empty;
ProviderDisplayName = providerDisplayName ?? string.Empty;
CourseName = string.IsNullOrWhiteSpace(courseName) ? "UNKNOWN COURSE" : courseName;
Cleared = cleared;
PlayCount = Math.Max(0, playCount);
ClearCount = Math.Max(0, clearCount);
BestStageReached = Math.Max(0, bestStageReached);
BestAccuracyPercent = Math.Max(0.0, Math.Min(100.0, bestAccuracyPercent));
BestTrophyRank = Math.Max(0, bestTrophyRank);
BestTrophyName = bestTrophyName ?? string.Empty;
}
public string CourseId { get; }
public string ProviderDisplayName { get; }
public string CourseName { get; }
public bool Cleared { get; }
public int PlayCount { get; }
public int ClearCount { get; }
public int BestStageReached { get; }
public double BestAccuracyPercent { get; }
public int BestTrophyRank { get; }
public string BestTrophyName { get; }
}
public readonly struct CourseCertificationBadge
{
public CourseCertificationBadge(CourseCollectionEntry course)
{
CourseId = course.CourseId;
ProviderDisplayName = course.ProviderDisplayName;
CourseName = course.CourseName;
BestAccuracyPercent = course.BestAccuracyPercent;
TrophyRank = course.BestTrophyRank;
TrophyName = course.BestTrophyName;
}
public string CourseId { get; }
public string ProviderDisplayName { get; }
public string CourseName { get; }
public double BestAccuracyPercent { get; }
public int TrophyRank { get; }
public string TrophyName { get; }
public string CertificationLabel => string.IsNullOrWhiteSpace(ProviderDisplayName)
? CourseName
: $"{ProviderDisplayName} // {CourseName}";
}
public sealed class ProfileCourseSnapshot
{
public static readonly ProfileCourseSnapshot Empty = new ProfileCourseSnapshot(
0,
0,
0,
Array.Empty<CourseCollectionEntry>());
public ProfileCourseSnapshot(
int recordedCourses,
int clearedCourses,
int totalPlayCount,
IReadOnlyList<CourseCollectionEntry> entries,
string featuredBadgeCourseId = null)
{
RecordedCourses = Math.Max(0, recordedCourses);
ClearedCourses = Math.Max(0, clearedCourses);
TotalPlayCount = Math.Max(0, totalPlayCount);
Entries = entries ?? Array.Empty<CourseCollectionEntry>();
CertificationBadges = BuildCertificationBadges(Entries);
FeaturedBadgeCourseId = ResolveFeaturedBadgeCourseId(
CertificationBadges,
featuredBadgeCourseId);
}
public int RecordedCourses { get; }
public int ClearedCourses { get; }
public int TotalPlayCount { get; }
public IReadOnlyList<CourseCollectionEntry> Entries { get; }
public IReadOnlyList<CourseCertificationBadge> CertificationBadges { get; }
public string FeaturedBadgeCourseId { get; }
public int BadgeCount => CertificationBadges.Count;
public bool HasFeaturedBadge => BadgeCount > 0;
public CourseCertificationBadge FeaturedBadge
{
get
{
foreach (var badge in CertificationBadges)
{
if (string.Equals(badge.CourseId, FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase))
return badge;
}
return default;
}
}
private static IReadOnlyList<CourseCertificationBadge> BuildCertificationBadges(
IReadOnlyList<CourseCollectionEntry> entries)
{
var badges = new List<CourseCertificationBadge>();
foreach (var entry in entries ?? Array.Empty<CourseCollectionEntry>())
{
if (entry.Cleared)
badges.Add(new CourseCertificationBadge(entry));
}
return badges;
}
private static string ResolveFeaturedBadgeCourseId(
IReadOnlyList<CourseCertificationBadge> badges,
string requestedCourseId)
{
var requested = (requestedCourseId ?? string.Empty).Trim();
foreach (var badge in badges ?? Array.Empty<CourseCertificationBadge>())
{
if (string.Equals(badge.CourseId, requested, StringComparison.OrdinalIgnoreCase))
return badge.CourseId;
}
return badges != null && badges.Count > 0 ? badges[0].CourseId : string.Empty;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: abc0e008a277e4267aad53209773e6bb
@@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ProjectQuint.Core.Charts;
namespace ProjectQuint.Core.Progression
{
public readonly struct RatingContribution
{
public RatingContribution(string chartId, double playRating)
{
ChartId = chartId ?? string.Empty;
PlayRating = Math.Max(0.0, playRating);
}
public string ChartId { get; }
public double PlayRating { get; }
}
public readonly struct RatingChartEntry
{
public RatingChartEntry(
string chartId,
string title,
string artist,
string difficultyLabel,
double accuracyPercent,
bool hardClear,
double playRating)
{
ChartId = chartId ?? string.Empty;
Title = string.IsNullOrWhiteSpace(title) ? "UNKNOWN CHART" : title;
Artist = artist ?? string.Empty;
DifficultyLabel = difficultyLabel ?? string.Empty;
AccuracyPercent = Math.Max(0.0, Math.Min(100.0, accuracyPercent));
HardClear = hardClear;
PlayRating = Math.Max(0.0, playRating);
}
public string ChartId { get; }
public string Title { get; }
public string Artist { get; }
public string DifficultyLabel { get; }
public double AccuracyPercent { get; }
public bool HardClear { get; }
public double PlayRating { get; }
}
public readonly struct RatingSummary
{
public RatingSummary(
string providerId,
string providerDisplayName,
int keyCount,
bool hasScratch,
double value,
int filledSlots,
IReadOnlyList<RatingChartEntry> bestPlays = null,
int activityPlayCount = 0)
{
ProviderId = (providerId ?? string.Empty).Trim();
ProviderDisplayName = string.IsNullOrWhiteSpace(providerDisplayName)
? ProviderId
: providerDisplayName.Trim();
KeyCount = Math.Max(0, keyCount);
HasScratch = hasScratch;
Value = Math.Max(0.0, value);
FilledSlots = Math.Max(0, Math.Min(RatingRules.BestCount, filledSlots));
BestPlays = bestPlays ?? Array.Empty<RatingChartEntry>();
ActivityPlayCount = Math.Max(0, activityPlayCount);
}
public string ProviderId { get; }
public string ProviderDisplayName { get; }
public int KeyCount { get; }
public bool HasScratch { get; }
public double Value { get; }
public int FilledSlots { get; }
public IReadOnlyList<RatingChartEntry> BestPlays { get; }
public int ActivityPlayCount { get; }
public bool HasContext =>
!string.IsNullOrWhiteSpace(ProviderId) &&
KeyCount >= 4 && KeyCount <= 7;
public string ContextLabel => !HasContext
? "RATING"
: $"{ProviderDisplayName.ToUpperInvariant()} {RatingRules.KeyModeLabel(KeyCount, HasScratch)} RATING";
}
public readonly struct RatingUpdate
{
public RatingUpdate(
bool eligible,
double playRating,
RatingSummary previous,
RatingSummary current)
{
Eligible = eligible;
PlayRating = Math.Max(0.0, playRating);
Previous = previous;
Current = current;
}
public bool Eligible { get; }
public double PlayRating { get; }
public RatingSummary Previous { get; }
public RatingSummary Current { get; }
public double Delta => Current.Value - Previous.Value;
}
public readonly struct ProfileProgressSnapshot
{
public ProfileProgressSnapshot(
string displayName,
long totalXp,
int validPlayCount,
double activePlaySeconds,
string lastRatingProviderId,
string lastRatingProviderDisplayName,
int lastRatingKeyCount,
bool lastRatingHasScratch)
{
DisplayName = string.IsNullOrWhiteSpace(displayName) ? "PLAYER" : displayName;
TotalXp = Math.Max(0, totalXp);
Level = ProfileLevelRules.LevelForTotalXp(TotalXp);
LevelStartXp = ProfileLevelRules.TotalXpForLevel(Level);
NextLevelXp = ProfileLevelRules.TotalXpForLevel(Level + 1);
ValidPlayCount = Math.Max(0, validPlayCount);
ActivePlaySeconds = Math.Max(0.0, activePlaySeconds);
LastRatingProviderId = (lastRatingProviderId ?? string.Empty).Trim();
LastRatingProviderDisplayName = string.IsNullOrWhiteSpace(lastRatingProviderDisplayName)
? LastRatingProviderId
: lastRatingProviderDisplayName.Trim();
LastRatingKeyCount = Math.Max(0, lastRatingKeyCount);
LastRatingHasScratch = lastRatingHasScratch;
}
public string DisplayName { get; }
public long TotalXp { get; }
public int Level { get; }
public long LevelStartXp { get; }
public long NextLevelXp { get; }
public int ValidPlayCount { get; }
public double ActivePlaySeconds { get; }
public string LastRatingProviderId { get; }
public string LastRatingProviderDisplayName { get; }
public int LastRatingKeyCount { get; }
public bool LastRatingHasScratch { get; }
public long XpIntoLevel => TotalXp - LevelStartXp;
public long XpForLevel => Math.Max(1, NextLevelXp - LevelStartXp);
public double LevelProgress => Math.Max(0.0, Math.Min(1.0, XpIntoLevel / (double)XpForLevel));
}
public readonly struct ProfileProgressUpdate
{
public ProfileProgressUpdate(
bool applied,
int xpAwarded,
ProfileProgressSnapshot previous,
ProfileProgressSnapshot current,
bool firstPlay,
bool firstNormalClear,
bool firstHardClear,
bool firstFullCombo,
bool firstAllPure)
{
Applied = applied;
XpAwarded = Math.Max(0, xpAwarded);
Previous = previous;
Current = current;
FirstPlay = firstPlay;
FirstNormalClear = firstNormalClear;
FirstHardClear = firstHardClear;
FirstFullCombo = firstFullCombo;
FirstAllPure = firstAllPure;
}
public bool Applied { get; }
public int XpAwarded { get; }
public ProfileProgressSnapshot Previous { get; }
public ProfileProgressSnapshot Current { get; }
public bool FirstPlay { get; }
public bool FirstNormalClear { get; }
public bool FirstHardClear { get; }
public bool FirstFullCombo { get; }
public bool FirstAllPure { get; }
public bool LeveledUp => Current.Level > Previous.Level;
}
public static class RatingRules
{
public const int RulesVersion = 5;
public const int BestCount = 30;
public const double HardClearBonus = 0.50;
public const double MinimumChartConstant = 0.50;
private static readonly double[] AccuracyAnchors =
{
0.0, 80.0, 90.0, 95.0, 97.0, 98.0, 99.0, 99.5, 100.0,
};
private static readonly double[] FactorAnchors =
{
0.0, 0.30, 0.60, 0.80, 0.88, 0.93, 0.97, 0.985, 1.0,
};
public static double AccuracyFactor(double accuracyPercent)
{
var accuracy = Math.Max(0.0, Math.Min(100.0, accuracyPercent));
for (var index = 1; index < AccuracyAnchors.Length; index++)
{
if (accuracy > AccuracyAnchors[index])
continue;
var start = AccuracyAnchors[index - 1];
var end = AccuracyAnchors[index];
var progress = end <= start ? 0.0 : (accuracy - start) / (end - start);
return FactorAnchors[index - 1] +
(FactorAnchors[index] - FactorAnchors[index - 1]) * progress;
}
return 1.0;
}
public static double CalculatePlayRating(
double chartConstant,
double accuracyPercent,
bool hardClear)
{
if (chartConstant <= 0.0)
return 0.0;
return chartConstant * AccuracyFactor(accuracyPercent) +
(hardClear ? HardClearBonus : 0.0);
}
public static RatingSummary CalculateBest30(
string providerId,
string providerDisplayName,
int keyCount,
bool hasScratch,
IEnumerable<RatingContribution> contributions)
{
var bestByChart = new Dictionary<string, double>(StringComparer.Ordinal);
foreach (var item in contributions ?? Array.Empty<RatingContribution>())
{
if (string.IsNullOrWhiteSpace(item.ChartId) || item.PlayRating <= 0.0)
continue;
if (!bestByChart.TryGetValue(item.ChartId, out var current) || item.PlayRating > current)
bestByChart[item.ChartId] = item.PlayRating;
}
var best = bestByChart.Values
.OrderByDescending(value => value)
.Take(BestCount)
.ToArray();
return new RatingSummary(
providerId,
providerDisplayName,
keyCount,
hasScratch,
best.Sum() / BestCount,
best.Length);
}
public static string KeyModeLabel(int keyCount, bool hasScratch) =>
ChartKeyMode.FormatLabel(keyCount, hasScratch);
public static bool TryResolveChartConstant(
string difficultyValue,
string difficultyLabel,
out double chartConstant)
{
if (!string.IsNullOrWhiteSpace(difficultyValue))
return TryParseChartConstant(difficultyValue, out chartConstant);
return TryParseChartConstant(difficultyLabel, out chartConstant);
}
public static bool TryParseChartConstant(string difficultyText, out double chartConstant)
{
chartConstant = 0.0;
var label = (difficultyText ?? string.Empty).Trim();
if (label.Length == 0)
return false;
var upper = label.ToUpperInvariant();
if (upper.Contains("GIMMICK") || upper.Contains("SPECIAL") ||
upper.Contains("DUMMY") || upper.Contains("OLD"))
return false;
var numericEnd = label.Length;
var plus = false;
if (numericEnd > 0 && label[numericEnd - 1] == '+')
{
plus = true;
numericEnd--;
while (numericEnd > 0 && char.IsWhiteSpace(label[numericEnd - 1]))
numericEnd--;
}
var start = numericEnd;
var dotSeen = false;
var digitSeen = false;
while (start > 0)
{
var character = label[start - 1];
if (char.IsDigit(character))
{
digitSeen = true;
start--;
continue;
}
if (character == '.' && !dotSeen)
{
dotSeen = true;
start--;
continue;
}
break;
}
if (!digitSeen)
return false;
if (start > 0 && (label[start - 1] == '-' || label[start - 1] == '.'))
return false;
if (!double.TryParse(
label.Substring(start, numericEnd - start),
NumberStyles.AllowDecimalPoint,
CultureInfo.InvariantCulture,
out chartConstant) ||
chartConstant < 0.0 ||
double.IsNaN(chartConstant) ||
double.IsInfinity(chartConstant))
{
chartConstant = 0.0;
return false;
}
// Some established tables start at level 0 (for example Satellite
// sl0). It is a real lowest tier, not a missing difficulty value.
// A positive half-step keeps it below level 1 while allowing it to
// participate in the same rating pipeline.
if (chartConstant <= 0.0)
chartConstant = MinimumChartConstant;
if (plus &&
Math.Abs(chartConstant - Math.Round(chartConstant)) < 0.000001)
chartConstant += 0.5;
return true;
}
}
public static class ProfileLevelRules
{
public const int XpRulesVersion = 1;
public static long TotalXpForLevel(int level)
{
var normalized = Math.Max(1, level) - 1L;
return 20L * normalized * normalized + 50L * normalized;
}
public static int NextLevelXp(int level) => checked(40 * Math.Max(1, level) + 30);
public static int LevelForTotalXp(long totalXp)
{
var xp = Math.Max(0L, totalXp);
var low = 1;
var high = 2;
while (TotalXpForLevel(high) <= xp && high < 1_000_000)
high *= 2;
while (low + 1 < high)
{
var middle = low + (high - low) / 2;
if (TotalXpForLevel(middle) <= xp)
low = middle;
else
high = middle;
}
return low;
}
public static int CalculateBaseXp(double activePlaySeconds, int judgedUnits)
{
var value = 20 +
(int)Math.Floor(Math.Max(0.0, activePlaySeconds) / 10.0) * 4 +
Math.Max(0, judgedUnits) / 100 * 2;
return Math.Max(20, Math.Min(100, value));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 459ea391724314c1397e5b3531e642d3