독립 검증 도구 추가와 앱 상태 책임 분리
This commit is contained in:
@@ -13,6 +13,10 @@
|
||||
*.csproj
|
||||
*.sln
|
||||
*.slnx
|
||||
!/Verification/**/*.csproj
|
||||
/Verification/**/bin/
|
||||
/Verification/**/obj/
|
||||
/Verification/**/TestResults/
|
||||
*.user
|
||||
*.userprefs
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
|
||||
namespace ProjectQuint.Core.Release
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class ProjectQuintUpdateManifest
|
||||
{
|
||||
public int schemaVersion;
|
||||
public string channel = string.Empty;
|
||||
public bool available;
|
||||
public string version = string.Empty;
|
||||
public int buildNumber;
|
||||
public string publishedAt = string.Empty;
|
||||
public string downloadUrl = string.Empty;
|
||||
public string notesUrl = string.Empty;
|
||||
public string sha256 = string.Empty;
|
||||
public long sizeBytes;
|
||||
}
|
||||
|
||||
public readonly struct ProjectQuintUpdateCheckResult
|
||||
{
|
||||
public ProjectQuintUpdateCheckResult(
|
||||
bool succeeded,
|
||||
ProjectQuintUpdateManifest manifest,
|
||||
string error)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
Manifest = manifest;
|
||||
Error = error ?? string.Empty;
|
||||
}
|
||||
|
||||
public bool Succeeded { get; }
|
||||
public ProjectQuintUpdateManifest Manifest { get; }
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9ccf2a029bb949a9a0962f8df10627a4
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace ProjectQuint.Core.Release
|
||||
{
|
||||
public static class UpdatePolicy
|
||||
{
|
||||
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
|
||||
|
||||
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
|
||||
manifest != null &&
|
||||
manifest.available &&
|
||||
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
|
||||
|
||||
public static bool ShouldOffer(
|
||||
ProjectQuintUpdateManifest manifest,
|
||||
string skippedVersion,
|
||||
bool manual) =>
|
||||
IsUpdateAvailable(manifest) &&
|
||||
(manual ||
|
||||
!string.Equals(
|
||||
manifest.version,
|
||||
skippedVersion?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
|
||||
{
|
||||
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
|
||||
return true;
|
||||
|
||||
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
|
||||
AutomaticCheckInterval;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8bcdf0d1dd20443ea0f4d55eb2954005
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Progression;
|
||||
using ProjectQuint.Library;
|
||||
using ProjectQuint.Persistence;
|
||||
|
||||
namespace ProjectQuint.Bootstrap
|
||||
{
|
||||
internal sealed class ProfileRatingThumbnailCatalog
|
||||
{
|
||||
private long _sourceSignature = long.MinValue;
|
||||
public IReadOnlyList<SongLibraryEntry> Songs { get; private set; } = Array.Empty<SongLibraryEntry>();
|
||||
public int Revision { get; private set; }
|
||||
|
||||
public void Refresh(IReadOnlyList<RatingSummary> contexts,
|
||||
IReadOnlyList<SongLibraryEntry> activeSongs, int songsRevision, BmsPackRegistry registry)
|
||||
{
|
||||
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var context in contexts ?? Array.Empty<RatingSummary>())
|
||||
{
|
||||
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entry.ChartId))
|
||||
requestedChartIds.Add(entry.ChartId);
|
||||
}
|
||||
}
|
||||
|
||||
var signature = 17L;
|
||||
unchecked
|
||||
{
|
||||
signature = signature * 31 + (songsRevision);
|
||||
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
|
||||
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
|
||||
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
|
||||
{
|
||||
var cachePath = registry.CachePathFor(pack);
|
||||
var cache = new FileInfo(cachePath);
|
||||
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
|
||||
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
|
||||
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
|
||||
}
|
||||
}
|
||||
if (signature == _sourceSignature)
|
||||
return;
|
||||
|
||||
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
|
||||
void AddSong(SongLibraryEntry song)
|
||||
{
|
||||
if (song == null)
|
||||
return;
|
||||
var chartId = ChartIdentityResolver.Resolve(song).Value;
|
||||
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
|
||||
songsByChartId.Add(chartId, song);
|
||||
}
|
||||
|
||||
foreach (var song in activeSongs ?? Array.Empty<SongLibraryEntry>())
|
||||
AddSong(song);
|
||||
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
|
||||
{
|
||||
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
|
||||
registry.CachePathFor(pack),
|
||||
pack.ContentRoot,
|
||||
pack.Id,
|
||||
requestedChartIds))
|
||||
AddSong(song);
|
||||
}
|
||||
|
||||
Songs = songsByChartId.Values.ToArray();
|
||||
_sourceSignature = signature;
|
||||
unchecked
|
||||
{
|
||||
Revision++;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: eaab54db67674a42ba9d968467641b78
|
||||
@@ -11,7 +11,9 @@ using ProjectQuint.Features.Gameplay;
|
||||
using ProjectQuint.Features.Library;
|
||||
using ProjectQuint.Features.Loading;
|
||||
using ProjectQuint.Features.Results;
|
||||
using ProjectQuint.Features.Profile;
|
||||
using ProjectQuint.Features.Settings;
|
||||
using ProjectQuint.Features.Updates;
|
||||
using ProjectQuint.Audio;
|
||||
using ProjectQuint.Core.Bms;
|
||||
using ProjectQuint.Core.Charts;
|
||||
@@ -138,31 +140,10 @@ namespace ProjectQuint.Bootstrap
|
||||
private Texture2D _profileImageTexture;
|
||||
private string _profileImageStatus = string.Empty;
|
||||
private string _profileTransferStatus = "PROFILE ARCHIVE READY";
|
||||
private string _updateStatus = "NOT CHECKED";
|
||||
private ProjectQuintUpdateManifest _availableUpdate;
|
||||
private bool _automaticUpdateCheckStarted;
|
||||
private bool _updateCheckRunning;
|
||||
private bool _updateOfferPending;
|
||||
private bool _updateModalOpen;
|
||||
private bool _updateDismissedThisRun;
|
||||
private int _updateModalSelection;
|
||||
private readonly UpdateSessionController _updateSession = new UpdateSessionController();
|
||||
private bool _profileImportConfirmationPending;
|
||||
private ProfileProgressSnapshot _profileSnapshot;
|
||||
private RatingSummary _homeRatingSummary;
|
||||
private RatingSummary _profileRatingSummary;
|
||||
private IReadOnlyList<RatingSummary> _ratingContexts = Array.Empty<RatingSummary>();
|
||||
private int _profileRatingContextIndex;
|
||||
private IReadOnlyList<SongLibraryEntry> _profileRatingThumbnailSongs =
|
||||
Array.Empty<SongLibraryEntry>();
|
||||
private int _profileRatingThumbnailSongsRevision;
|
||||
private long _profileRatingThumbnailSourceSignature = long.MinValue;
|
||||
private ProfileCollectionSnapshot _profileCollection = ProfileCollectionSnapshot.Empty;
|
||||
private ProfileCourseSnapshot _profileCourses = ProfileCourseSnapshot.Empty;
|
||||
private bool _profileModalOpen;
|
||||
private int _profileModalTab;
|
||||
private int _profileBadgeSelection;
|
||||
private int _profileCollectionColumn;
|
||||
private readonly int[] _profileCollectionSelections = new int[3];
|
||||
private readonly ProfileSessionController _profileSession = new ProfileSessionController();
|
||||
private readonly ProfileRatingThumbnailCatalog _profileThumbnails = new ProfileRatingThumbnailCatalog();
|
||||
private string _libraryRoot = string.Empty;
|
||||
private string _libraryLoadError = string.Empty;
|
||||
private bool _libraryReady;
|
||||
@@ -205,8 +186,7 @@ namespace ProjectQuint.Bootstrap
|
||||
private bool _autoScratchEnabled;
|
||||
private bool _gameplayDiagnosticsEnabled;
|
||||
|
||||
// Compatibility accessors keep this first extraction behavior-neutral. Feature controllers
|
||||
// can receive GameSettings directly as they are split out in later refactoring stages.
|
||||
// These accessors share the persisted settings object; they do not keep local copies.
|
||||
private Key[] _laneKeys => _settings.LaneKeys;
|
||||
private Key _centerAuxKey => _settings.CenterAuxKey;
|
||||
private bool _reactiveKeySoundsEnabled { get => _settings.ReactiveKeySoundsEnabled; set => _settings.ReactiveKeySoundsEnabled = value; }
|
||||
@@ -854,7 +834,7 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
_libraryThumbnail = null;
|
||||
_stageTexture = null;
|
||||
_profileSnapshot = currentProfile;
|
||||
_profileSession.SetPreviewSnapshot(currentProfile);
|
||||
_resultSession.BeginPlayback();
|
||||
_resultSession.SetCommitOutcome(new PlayCommitOutcome(profileUpdate, ratingUpdate));
|
||||
_resultSession.BeginResults(
|
||||
@@ -872,9 +852,8 @@ namespace ProjectQuint.Bootstrap
|
||||
{
|
||||
var key = (surface ?? string.Empty).Trim().ToLowerInvariant();
|
||||
var selectedSong = _libraryController?.State?.SelectedSong;
|
||||
_updateModalOpen = false;
|
||||
_updateOfferPending = false;
|
||||
_profileModalOpen = false;
|
||||
_updateSession.Cancel();
|
||||
_profileSession.Close();
|
||||
_packCacheManagerOpen = false;
|
||||
_pinnedPackCacheConfirmationPending = false;
|
||||
_packRemovalConfirmationPending = false;
|
||||
@@ -894,7 +873,7 @@ namespace ProjectQuint.Bootstrap
|
||||
_mode = AppMode.TeamDeniaBranding;
|
||||
break;
|
||||
case "update":
|
||||
_availableUpdate = new ProjectQuintUpdateManifest
|
||||
_updateSession.ShowPreview(new ProjectQuintUpdateManifest
|
||||
{
|
||||
schemaVersion = 1,
|
||||
channel = ProjectQuintReleaseVersion.ReleaseChannel,
|
||||
@@ -908,10 +887,7 @@ namespace ProjectQuint.Bootstrap
|
||||
notesUrl = "/changelog/",
|
||||
sha256 = new string('a', 64),
|
||||
sizeBytes = 78_000_000,
|
||||
};
|
||||
_updateModalSelection = 0;
|
||||
_updateOfferPending = false;
|
||||
_updateModalOpen = true;
|
||||
});
|
||||
_mode = AppMode.Home;
|
||||
break;
|
||||
case "pack":
|
||||
@@ -1120,7 +1096,7 @@ namespace ProjectQuint.Bootstrap
|
||||
HandleExternalAudioConfigurationChange();
|
||||
TryStartAutomaticUpdateCheck();
|
||||
TryPresentPendingUpdate();
|
||||
if (_updateModalOpen)
|
||||
if (_updateSession.IsModalOpen)
|
||||
{
|
||||
UpdateUpdateNoticeInput();
|
||||
FlushPendingLibraryViewState(force: false);
|
||||
@@ -1194,15 +1170,15 @@ namespace ProjectQuint.Bootstrap
|
||||
: Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime;
|
||||
_startupBrandingPresenter?.Update(_mode, startupElapsed);
|
||||
|
||||
var homeVisible = _mode == AppMode.Home && !_profileModalOpen;
|
||||
var homeVisible = _mode == AppMode.Home && !_profileSession.IsOpen;
|
||||
_homeScreenPresenter?.SetVisible(homeVisible);
|
||||
_updateNoticePresenter?.SetVisible(_updateModalOpen);
|
||||
if (_updateModalOpen && _availableUpdate != null)
|
||||
_updateNoticePresenter?.SetVisible(_updateSession.IsModalOpen);
|
||||
if (_updateSession.IsModalOpen && _updateSession.Manifest != null)
|
||||
{
|
||||
_updateNoticePresenter?.Update(
|
||||
_availableUpdate.version,
|
||||
_availableUpdate.buildNumber,
|
||||
_updateModalSelection,
|
||||
_updateSession.Manifest.version,
|
||||
_updateSession.Manifest.buildNumber,
|
||||
_updateSession.Selection,
|
||||
_sparkleUpdater?.IsReady == true);
|
||||
}
|
||||
|
||||
@@ -1277,7 +1253,7 @@ namespace ProjectQuint.Bootstrap
|
||||
_resultSession.RatingUpdate,
|
||||
_resultSession.RecordSaveError,
|
||||
_resultSession.PlayedUtcTicks,
|
||||
_profileSnapshot,
|
||||
_profileSession.Snapshot,
|
||||
_resultsSelection,
|
||||
Math.Max(0.0, Time.realtimeSinceStartupAsDouble - _resultEnteredRealtime),
|
||||
_gameplaySession?.IsAutoScratch == true);
|
||||
@@ -1290,7 +1266,7 @@ namespace ProjectQuint.Bootstrap
|
||||
_coursePassed,
|
||||
_courseTrophy,
|
||||
_courseBadgeEarned,
|
||||
_profileSnapshot,
|
||||
_profileSession.Snapshot,
|
||||
_courseResultSelection);
|
||||
break;
|
||||
case AppMode.Error:
|
||||
@@ -1374,7 +1350,7 @@ namespace ProjectQuint.Bootstrap
|
||||
? _catalogMatchedCharts
|
||||
: -1,
|
||||
selectedTableDataAvailable,
|
||||
_profileSnapshot,
|
||||
_profileSession.Snapshot,
|
||||
_status,
|
||||
_packKeyModeModalOpen,
|
||||
_packKeyModeModalSelection,
|
||||
@@ -1439,7 +1415,7 @@ namespace ProjectQuint.Bootstrap
|
||||
_gameplaySkin,
|
||||
_bgaPlaybackMode,
|
||||
_reactiveKeySoundsEnabled,
|
||||
_profileSnapshot,
|
||||
_profileSession.Snapshot,
|
||||
library.ViewRevision);
|
||||
break;
|
||||
}
|
||||
@@ -1453,7 +1429,7 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
var optionsVisible = _mode == AppMode.Options;
|
||||
var profileVisible =
|
||||
_profileModalOpen &&
|
||||
_profileSession.IsOpen &&
|
||||
(_mode == AppMode.Home || _mode == AppMode.Library);
|
||||
var courseVisible = _mode == AppMode.CourseSelect;
|
||||
_optionsProfileCoursePresenter.SetOptionsVisible(optionsVisible);
|
||||
@@ -1504,27 +1480,27 @@ namespace ProjectQuint.Bootstrap
|
||||
GameSettings.FormatLaneKeyLabel(_settings.ScratchKey),
|
||||
_rebindLane,
|
||||
_profileTransferStatus,
|
||||
_updateStatus,
|
||||
_updateSession.Status,
|
||||
_profileImportConfirmationPending,
|
||||
_profileSnapshot,
|
||||
_profileSession.Snapshot,
|
||||
BuildAudioSyncCalibrationView());
|
||||
}
|
||||
|
||||
if (profileVisible)
|
||||
{
|
||||
_optionsProfileCoursePresenter.UpdateProfile(
|
||||
_profileSnapshot,
|
||||
_profileRatingSummary,
|
||||
_profileCollection,
|
||||
_profileCourses,
|
||||
_profileModalTab,
|
||||
_profileRatingContextIndex,
|
||||
_ratingContexts.Count,
|
||||
_profileBadgeSelection,
|
||||
_profileCollectionColumn,
|
||||
_profileCollectionSelections,
|
||||
_profileRatingThumbnailSongs,
|
||||
_profileRatingThumbnailSongsRevision);
|
||||
_profileSession.Snapshot,
|
||||
_profileSession.RatingSummary,
|
||||
_profileSession.Collection,
|
||||
_profileSession.Courses,
|
||||
_profileSession.Tab,
|
||||
_profileSession.RatingContextIndex,
|
||||
_profileSession.RatingContexts.Count,
|
||||
_profileSession.BadgeSelection,
|
||||
_profileSession.CollectionColumn,
|
||||
_profileSession.CollectionSelections,
|
||||
_profileThumbnails.Songs,
|
||||
_profileThumbnails.Revision);
|
||||
}
|
||||
|
||||
if (courseVisible)
|
||||
@@ -1536,7 +1512,7 @@ namespace ProjectQuint.Bootstrap
|
||||
EffectiveCourseModifier,
|
||||
_noteSpeed,
|
||||
_courseStatus,
|
||||
_profileSnapshot);
|
||||
_profileSession.Snapshot);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2178,14 +2154,8 @@ namespace ProjectQuint.Bootstrap
|
||||
switch (action)
|
||||
{
|
||||
case HomeMenuAction.Play:
|
||||
_packSelection = Mathf.Clamp(
|
||||
ResolveActivePackSelection(),
|
||||
0,
|
||||
Math.Max(0, (_packRegistry?.Packs.Count ?? 0) + 1));
|
||||
_status = _packRegistry?.Packs.Count > 0
|
||||
? $"{_packRegistry.Packs.Count:N0} BMS PACKS REGISTERED"
|
||||
: "SELECT A FOLDER OR ZIP TO BEGIN";
|
||||
_mode = AppMode.PackSelect;
|
||||
_flow.TryBeginPlayFromHome(
|
||||
ResolveActivePackSelection(), _packRegistry?.Packs.Count ?? 0);
|
||||
break;
|
||||
case HomeMenuAction.Options:
|
||||
OpenOptions(AppMode.Home);
|
||||
@@ -4947,99 +4917,49 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
private void TryStartAutomaticUpdateCheck()
|
||||
{
|
||||
if (_automaticUpdateCheckStarted ||
|
||||
_settings == null ||
|
||||
_mode != AppMode.Home)
|
||||
return;
|
||||
|
||||
_automaticUpdateCheckStarted = true;
|
||||
if (Application.isEditor)
|
||||
return;
|
||||
if (!ProjectQuintUpdateService.ShouldCheckAutomatically(
|
||||
_settings.LastUpdateCheckUtcTicks,
|
||||
DateTime.UtcNow.Ticks))
|
||||
{
|
||||
_updateStatus = "CHECKED RECENTLY";
|
||||
return;
|
||||
}
|
||||
|
||||
RequestUpdateCheck(manual: false);
|
||||
if (_settings != null && _updateSession.TryBeginAutomaticCheck(
|
||||
_mode, Application.isEditor, _settings.LastUpdateCheckUtcTicks,
|
||||
DateTime.UtcNow.Ticks, out var generation))
|
||||
StartCoroutine(CheckForUpdates(generation));
|
||||
}
|
||||
|
||||
private void RequestUpdateCheck(bool manual)
|
||||
{
|
||||
if (_updateCheckRunning)
|
||||
{
|
||||
if (manual)
|
||||
_updateStatus = "CHECKING...";
|
||||
return;
|
||||
}
|
||||
|
||||
if (manual)
|
||||
{
|
||||
_updateDismissedThisRun = false;
|
||||
_updateOfferPending = false;
|
||||
}
|
||||
_updateStatus = "CHECKING...";
|
||||
StartCoroutine(CheckForUpdates(manual));
|
||||
if (_updateSession.TryBeginCheck(manual, out var generation))
|
||||
StartCoroutine(CheckForUpdates(generation));
|
||||
}
|
||||
|
||||
private IEnumerator CheckForUpdates(bool manual)
|
||||
private IEnumerator CheckForUpdates(int generation)
|
||||
{
|
||||
_updateCheckRunning = true;
|
||||
ProjectQuintUpdateCheckResult result = default;
|
||||
yield return ProjectQuintUpdateService.Check(value => result = value);
|
||||
_updateCheckRunning = false;
|
||||
|
||||
if (!result.Succeeded)
|
||||
try
|
||||
{
|
||||
if (manual)
|
||||
_updateStatus = "CHECK FAILED";
|
||||
else
|
||||
_updateStatus = "NOT CHECKED";
|
||||
Debug.LogWarning($"[ProjectQuint Update] {result.Error}");
|
||||
yield break;
|
||||
ProjectQuintUpdateCheckResult result = default;
|
||||
yield return ProjectQuintUpdateService.Check(value => result = value);
|
||||
if (!_updateSession.CompleteCheck(generation, result,
|
||||
_settings.SkippedUpdateVersion, DateTime.UtcNow.Ticks))
|
||||
yield break;
|
||||
if (!result.Succeeded || result.Manifest == null)
|
||||
{
|
||||
Debug.LogWarning($"[ProjectQuint Update] {_updateSession.Error}");
|
||||
yield break;
|
||||
}
|
||||
_settings.LastUpdateCheckUtcTicks = _updateSession.LastSuccessfulCheckUtcTicks;
|
||||
PersistSettings();
|
||||
}
|
||||
|
||||
_settings.LastUpdateCheckUtcTicks = DateTime.UtcNow.Ticks;
|
||||
PersistSettings();
|
||||
_availableUpdate = result.Manifest;
|
||||
|
||||
if (!ProjectQuintUpdateService.IsUpdateAvailable(_availableUpdate))
|
||||
finally
|
||||
{
|
||||
_updateStatus = $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}";
|
||||
yield break;
|
||||
_updateSession.AbortCheck(generation);
|
||||
}
|
||||
|
||||
_updateStatus = $"AVAILABLE // {_availableUpdate.version.ToUpperInvariant()}";
|
||||
if (ProjectQuintUpdateService.ShouldOffer(
|
||||
_availableUpdate,
|
||||
_settings.SkippedUpdateVersion,
|
||||
manual))
|
||||
_updateOfferPending = true;
|
||||
}
|
||||
|
||||
private void TryPresentPendingUpdate()
|
||||
{
|
||||
if (!_updateOfferPending ||
|
||||
_updateModalOpen ||
|
||||
_updateDismissedThisRun ||
|
||||
_availableUpdate == null)
|
||||
return;
|
||||
|
||||
var safeHome = _mode == AppMode.Home && !_profileModalOpen;
|
||||
var safeOptions =
|
||||
_mode == AppMode.Options &&
|
||||
_audioSyncCalibration == null &&
|
||||
string.IsNullOrWhiteSpace(_audioSyncCalibrationError) &&
|
||||
!_packCacheManagerOpen &&
|
||||
!_profileImportConfirmationPending;
|
||||
if (!safeHome && !safeOptions)
|
||||
return;
|
||||
|
||||
_updateOfferPending = false;
|
||||
_updateModalSelection = 0;
|
||||
_updateModalOpen = true;
|
||||
var blocked = (_mode == AppMode.Home && _profileSession.IsOpen) ||
|
||||
(_mode == AppMode.Options &&
|
||||
(_audioSyncCalibration != null ||
|
||||
!string.IsNullOrWhiteSpace(_audioSyncCalibrationError) ||
|
||||
_packCacheManagerOpen || _profileImportConfirmationPending));
|
||||
_updateSession.TryPresent(_mode, blocked);
|
||||
}
|
||||
|
||||
private void UpdateUpdateNoticeInput()
|
||||
@@ -5056,19 +4976,19 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
if (keyboard.leftArrowKey.wasPressedThisFrame ||
|
||||
keyboard.upArrowKey.wasPressedThisFrame)
|
||||
_updateModalSelection = (_updateModalSelection + 2) % 3;
|
||||
_updateSession.MoveSelection(-1);
|
||||
else if (keyboard.rightArrowKey.wasPressedThisFrame ||
|
||||
keyboard.downArrowKey.wasPressedThisFrame)
|
||||
_updateModalSelection = (_updateModalSelection + 1) % 3;
|
||||
_updateSession.MoveSelection(1);
|
||||
|
||||
if (keyboard.enterKey.wasPressedThisFrame ||
|
||||
keyboard.numpadEnterKey.wasPressedThisFrame)
|
||||
HandleUpdateNoticeAction((UpdateNoticeAction)_updateModalSelection);
|
||||
HandleUpdateNoticeAction((UpdateNoticeAction)_updateSession.Selection);
|
||||
}
|
||||
|
||||
private void HandleUpdateNoticeAction(UpdateNoticeAction action)
|
||||
{
|
||||
if (!_updateModalOpen || _availableUpdate == null)
|
||||
if (!_updateSession.IsModalOpen || _updateSession.Manifest == null)
|
||||
return;
|
||||
|
||||
switch (action)
|
||||
@@ -5078,7 +4998,7 @@ namespace ProjectQuint.Bootstrap
|
||||
if (_sparkleUpdater != null &&
|
||||
_sparkleUpdater.TryCheckForUpdates(out sparkleError))
|
||||
{
|
||||
_updateStatus = "OPENING SECURE UPDATE INSTALLER...";
|
||||
_updateSession.MarkInstallerOpening();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -5088,14 +5008,12 @@ namespace ProjectQuint.Bootstrap
|
||||
}
|
||||
break;
|
||||
case UpdateNoticeAction.Skip:
|
||||
_settings.SkippedUpdateVersion = _availableUpdate.version;
|
||||
_settings.SkippedUpdateVersion = _updateSession.Manifest.version;
|
||||
PersistSettings();
|
||||
break;
|
||||
}
|
||||
|
||||
_updateDismissedThisRun = true;
|
||||
_updateOfferPending = false;
|
||||
_updateModalOpen = false;
|
||||
_updateSession.Dismiss();
|
||||
}
|
||||
|
||||
private void PersistSettings() => _settingsRepository.Save(_settings);
|
||||
@@ -5171,7 +5089,7 @@ namespace ProjectQuint.Bootstrap
|
||||
{
|
||||
if (keyboard == null)
|
||||
return false;
|
||||
if (_profileModalOpen)
|
||||
if (_profileSession.IsOpen)
|
||||
{
|
||||
if (keyboard.iKey.wasPressedThisFrame)
|
||||
{
|
||||
@@ -5185,43 +5103,43 @@ namespace ProjectQuint.Bootstrap
|
||||
return true;
|
||||
}
|
||||
if (keyboard.leftShiftKey.wasPressedThisFrame)
|
||||
_profileModalTab = (_profileModalTab + 3) % 4;
|
||||
_profileSession.ShiftTab(-1);
|
||||
if (keyboard.rightShiftKey.wasPressedThisFrame)
|
||||
_profileModalTab = (_profileModalTab + 1) % 4;
|
||||
if (_profileModalTab == 1 && _ratingContexts.Count > 1)
|
||||
_profileSession.ShiftTab(1);
|
||||
if (_profileSession.Tab == 1 && _profileSession.RatingContexts.Count > 1)
|
||||
{
|
||||
if (keyboard.upArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileRatingContext(-1);
|
||||
_profileSession.ShiftRatingContext(-1);
|
||||
if (keyboard.downArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileRatingContext(1);
|
||||
_profileSession.ShiftRatingContext(1);
|
||||
}
|
||||
if (_profileModalTab == 2)
|
||||
if (_profileSession.Tab == 2)
|
||||
{
|
||||
if (keyboard.leftArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileCollectionColumn(-1);
|
||||
_profileSession.ShiftCollectionColumn(-1);
|
||||
if (keyboard.rightArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileCollectionColumn(1);
|
||||
_profileSession.ShiftCollectionColumn(1);
|
||||
if (keyboard.upArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileCollectionSelection(-1);
|
||||
_profileSession.ShiftCollectionSelection(-1);
|
||||
if (keyboard.downArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileCollectionSelection(1);
|
||||
_profileSession.ShiftCollectionSelection(1);
|
||||
}
|
||||
if (_profileModalTab == 3 && _profileCourses.BadgeCount > 0)
|
||||
if (_profileSession.Tab == 3 && _profileSession.Courses.BadgeCount > 0)
|
||||
{
|
||||
if (keyboard.upArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileBadgeSelection(-1);
|
||||
_profileSession.ShiftBadge(-1);
|
||||
if (keyboard.downArrowKey.wasPressedThisFrame)
|
||||
ShiftProfileBadgeSelection(1);
|
||||
_profileSession.ShiftBadge(1);
|
||||
if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame)
|
||||
SetFeaturedProfileBadge(_profileBadgeSelection);
|
||||
SetFeaturedProfileBadge(_profileSession.BadgeSelection);
|
||||
}
|
||||
var enterCloses = _profileModalTab != 3 || _profileCourses.BadgeCount == 0;
|
||||
var enterCloses = _profileSession.Tab != 3 || _profileSession.Courses.BadgeCount == 0;
|
||||
if (keyboard.escapeKey.wasPressedThisFrame ||
|
||||
keyboard.f8Key.wasPressedThisFrame ||
|
||||
keyboard.f11Key.wasPressedThisFrame ||
|
||||
enterCloses && (keyboard.enterKey.wasPressedThisFrame ||
|
||||
keyboard.numpadEnterKey.wasPressedThisFrame))
|
||||
_profileModalOpen = false;
|
||||
_profileSession.Close();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5234,9 +5152,9 @@ namespace ProjectQuint.Bootstrap
|
||||
private void OpenProfileModal(int tab)
|
||||
{
|
||||
RefreshProfileSummary();
|
||||
RefreshProfileRatingThumbnailSongs();
|
||||
_profileModalTab = Mathf.Clamp(tab, 0, 3);
|
||||
_profileModalOpen = true;
|
||||
_profileThumbnails.Refresh(_profileSession.RatingContexts,
|
||||
_libraryController?.State?.AllSongs, _libraryController?.State?.SongsRevision ?? 0, _packRegistry);
|
||||
_profileSession.Open(tab);
|
||||
}
|
||||
|
||||
private void HandleProfileModalUi(ProfileModalUiResult profileResult)
|
||||
@@ -5246,19 +5164,19 @@ namespace ProjectQuint.Bootstrap
|
||||
if (profileResult.ResetProfileImage)
|
||||
ResetProfileImage();
|
||||
if (profileResult.SelectedTab >= 0)
|
||||
_profileModalTab = profileResult.SelectedTab;
|
||||
_profileSession.SelectTab(profileResult.SelectedTab);
|
||||
if (profileResult.RatingContextDelta != 0)
|
||||
ShiftProfileRatingContext(profileResult.RatingContextDelta);
|
||||
_profileSession.ShiftRatingContext(profileResult.RatingContextDelta);
|
||||
if (profileResult.SelectedBadgeIndex >= 0)
|
||||
_profileBadgeSelection = profileResult.SelectedBadgeIndex;
|
||||
_profileSession.SelectBadge(profileResult.SelectedBadgeIndex);
|
||||
if (profileResult.SelectedCollectionColumn >= 0)
|
||||
SetProfileCollectionSelection(
|
||||
_profileSession.SelectCollection(
|
||||
profileResult.SelectedCollectionColumn,
|
||||
profileResult.SelectedCollectionIndex);
|
||||
if (!string.IsNullOrWhiteSpace(profileResult.FeaturedBadgeCourseId))
|
||||
SetFeaturedProfileBadge(_profileBadgeSelection);
|
||||
SetFeaturedProfileBadge(_profileSession.BadgeSelection);
|
||||
if (profileResult.Close)
|
||||
_profileModalOpen = false;
|
||||
_profileSession.Close();
|
||||
}
|
||||
|
||||
private void LoadProfileImage()
|
||||
@@ -6193,292 +6111,32 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
private void RefreshProfileSummary()
|
||||
{
|
||||
_profileSnapshot = _progressionRepository?.Snapshot() ?? default;
|
||||
_profileCollection = SongRecordRepository.Shared.CalculateCollection();
|
||||
_profileCourses = CourseRecordRepository.Shared.CalculateCollection(
|
||||
_progressionRepository?.FeaturedCourseBadgeId);
|
||||
_ratingContexts = BuildProfileRatingContexts(
|
||||
SongRecordRepository.Shared.CalculateRatingContexts(),
|
||||
_difficultyTableRegistry?.Tables,
|
||||
_libraryController?.State?.AllSongs,
|
||||
_activeDifficultyTable?.Id,
|
||||
_profileSnapshot,
|
||||
SongRecordRepository.Shared.CalculateRating);
|
||||
_homeRatingSummary = _profileSnapshot.LastRatingKeyCount > 0 &&
|
||||
!string.IsNullOrWhiteSpace(_profileSnapshot.LastRatingProviderId)
|
||||
? SongRecordRepository.Shared.CalculateRating(
|
||||
_profileSnapshot.LastRatingProviderId,
|
||||
_profileSnapshot.LastRatingProviderDisplayName,
|
||||
_profileSnapshot.LastRatingKeyCount,
|
||||
_profileSnapshot.LastRatingHasScratch)
|
||||
: SongRecordRepository.Shared.CalculateMostRecentRatingContext(
|
||||
_profileSnapshot.LastRatingKeyCount,
|
||||
_profileSnapshot.LastRatingHasScratch);
|
||||
_profileRatingContextIndex = FindRatingContextIndex(_homeRatingSummary);
|
||||
if (_ratingContexts.Count > 0)
|
||||
_profileRatingContextIndex = 0;
|
||||
_profileRatingSummary = _ratingContexts.Count > 0
|
||||
? _ratingContexts[_profileRatingContextIndex]
|
||||
: _homeRatingSummary;
|
||||
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
|
||||
ClampProfileCollectionSelections();
|
||||
}
|
||||
|
||||
private void RefreshProfileRatingThumbnailSongs()
|
||||
{
|
||||
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var context in _ratingContexts ?? Array.Empty<RatingSummary>())
|
||||
{
|
||||
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(entry.ChartId))
|
||||
requestedChartIds.Add(entry.ChartId);
|
||||
}
|
||||
}
|
||||
|
||||
var signature = 17L;
|
||||
unchecked
|
||||
{
|
||||
signature = signature * 31 + (_libraryController?.State?.SongsRevision ?? 0);
|
||||
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
|
||||
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
|
||||
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
|
||||
{
|
||||
var cachePath = _packRegistry.CachePathFor(pack);
|
||||
var cache = new FileInfo(cachePath);
|
||||
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
|
||||
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
|
||||
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
|
||||
}
|
||||
}
|
||||
if (signature == _profileRatingThumbnailSourceSignature)
|
||||
return;
|
||||
|
||||
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
|
||||
void AddSong(SongLibraryEntry song)
|
||||
{
|
||||
if (song == null)
|
||||
return;
|
||||
var chartId = ChartIdentityResolver.Resolve(song).Value;
|
||||
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
|
||||
songsByChartId.Add(chartId, song);
|
||||
}
|
||||
|
||||
foreach (var song in _libraryController?.State?.AllSongs ?? Array.Empty<SongLibraryEntry>())
|
||||
AddSong(song);
|
||||
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
|
||||
{
|
||||
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
|
||||
_packRegistry.CachePathFor(pack),
|
||||
pack.ContentRoot,
|
||||
pack.Id,
|
||||
requestedChartIds))
|
||||
AddSong(song);
|
||||
}
|
||||
|
||||
_profileRatingThumbnailSongs = songsByChartId.Values.ToArray();
|
||||
_profileRatingThumbnailSourceSignature = signature;
|
||||
unchecked
|
||||
{
|
||||
_profileRatingThumbnailSongsRevision++;
|
||||
}
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<RatingSummary> BuildProfileRatingContexts(
|
||||
IReadOnlyList<RatingSummary> recordedContexts,
|
||||
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
|
||||
IReadOnlyList<SongLibraryEntry> activeSongs,
|
||||
string activeTableId,
|
||||
ProfileProgressSnapshot profile,
|
||||
Func<string, string, int, bool, RatingSummary> resolveRating)
|
||||
{
|
||||
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
|
||||
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
|
||||
{
|
||||
if (table == null || string.IsNullOrWhiteSpace(table.Id))
|
||||
continue;
|
||||
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
|
||||
? table.Id.Trim()
|
||||
: table.DisplayName.Trim();
|
||||
}
|
||||
|
||||
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
|
||||
return;
|
||||
var normalizedId = providerId.Trim();
|
||||
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
|
||||
if (contexts.ContainsKey(key))
|
||||
return;
|
||||
var displayName = string.IsNullOrWhiteSpace(providerName)
|
||||
? tableNames.TryGetValue(normalizedId, out var registeredName)
|
||||
? registeredName
|
||||
: normalizedId
|
||||
: providerName.Trim();
|
||||
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
|
||||
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
|
||||
}
|
||||
|
||||
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
|
||||
{
|
||||
if (!recorded.HasContext)
|
||||
continue;
|
||||
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
|
||||
}
|
||||
|
||||
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
|
||||
{
|
||||
if (table == null)
|
||||
continue;
|
||||
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
|
||||
if (preset?.DefaultKeyCount >= 4)
|
||||
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
|
||||
preset.Value.DefaultHasScratch);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(activeTableId))
|
||||
{
|
||||
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
|
||||
? registeredName
|
||||
: activeTableId;
|
||||
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
|
||||
.Where(song => song?.Metadata != null &&
|
||||
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
|
||||
song.Catalog?.HasDifficulty == true &&
|
||||
string.Equals(song.Catalog.ProviderId, activeTableId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
|
||||
.Distinct())
|
||||
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
|
||||
}
|
||||
|
||||
AddResolved(
|
||||
profile.LastRatingProviderId,
|
||||
profile.LastRatingProviderDisplayName,
|
||||
profile.LastRatingKeyCount,
|
||||
profile.LastRatingHasScratch);
|
||||
|
||||
return contexts.Values
|
||||
.OrderByDescending(summary => summary.ActivityPlayCount)
|
||||
.ThenByDescending(summary => summary.FilledSlots)
|
||||
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(summary => summary.KeyCount)
|
||||
.ThenBy(summary => summary.HasScratch)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
|
||||
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
|
||||
|
||||
private int FindRatingContextIndex(RatingSummary target)
|
||||
{
|
||||
for (var index = 0; index < _ratingContexts.Count; index++)
|
||||
{
|
||||
var context = _ratingContexts[index];
|
||||
if (string.Equals(context.ProviderId, target.ProviderId, StringComparison.OrdinalIgnoreCase) &&
|
||||
context.KeyCount == target.KeyCount && context.HasScratch == target.HasScratch)
|
||||
return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void ShiftProfileRatingContext(int delta)
|
||||
{
|
||||
if (_ratingContexts.Count == 0 || delta == 0)
|
||||
return;
|
||||
_profileRatingContextIndex = (_profileRatingContextIndex + delta) % _ratingContexts.Count;
|
||||
if (_profileRatingContextIndex < 0)
|
||||
_profileRatingContextIndex += _ratingContexts.Count;
|
||||
_profileRatingSummary = _ratingContexts[_profileRatingContextIndex];
|
||||
}
|
||||
|
||||
private IReadOnlyList<CollectionGroupSummary> ProfileCollectionGroups(int column)
|
||||
{
|
||||
return Mathf.Clamp(column, 0, 2) switch
|
||||
{
|
||||
0 => _profileCollection.Packs,
|
||||
1 => _profileCollection.KeyModes,
|
||||
_ => _profileCollection.Difficulties,
|
||||
};
|
||||
}
|
||||
|
||||
private void ShiftProfileCollectionColumn(int delta)
|
||||
{
|
||||
if (delta == 0)
|
||||
return;
|
||||
_profileCollectionColumn = (_profileCollectionColumn + delta) % 3;
|
||||
if (_profileCollectionColumn < 0)
|
||||
_profileCollectionColumn += 3;
|
||||
ClampProfileCollectionSelections();
|
||||
}
|
||||
|
||||
private void ShiftProfileCollectionSelection(int delta)
|
||||
{
|
||||
var groups = ProfileCollectionGroups(_profileCollectionColumn);
|
||||
if (groups == null || groups.Count == 0 || delta == 0)
|
||||
return;
|
||||
var current = _profileCollectionSelections[_profileCollectionColumn];
|
||||
current = (current + delta) % groups.Count;
|
||||
if (current < 0)
|
||||
current += groups.Count;
|
||||
_profileCollectionSelections[_profileCollectionColumn] = current;
|
||||
}
|
||||
|
||||
private void SetProfileCollectionSelection(int column, int index)
|
||||
{
|
||||
_profileCollectionColumn = Mathf.Clamp(column, 0, 2);
|
||||
var groups = ProfileCollectionGroups(_profileCollectionColumn);
|
||||
_profileCollectionSelections[_profileCollectionColumn] = groups == null || groups.Count == 0
|
||||
? 0
|
||||
: Mathf.Clamp(index, 0, groups.Count - 1);
|
||||
}
|
||||
|
||||
private void ClampProfileCollectionSelections()
|
||||
{
|
||||
_profileCollectionColumn = Mathf.Clamp(_profileCollectionColumn, 0, 2);
|
||||
for (var column = 0; column < _profileCollectionSelections.Length; column++)
|
||||
{
|
||||
var groups = ProfileCollectionGroups(column);
|
||||
_profileCollectionSelections[column] = groups == null || groups.Count == 0
|
||||
? 0
|
||||
: Mathf.Clamp(_profileCollectionSelections[column], 0, groups.Count - 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static int FindFeaturedBadgeIndex(ProfileCourseSnapshot courses)
|
||||
{
|
||||
if (courses == null)
|
||||
return 0;
|
||||
for (var index = 0; index < courses.CertificationBadges.Count; index++)
|
||||
{
|
||||
if (string.Equals(
|
||||
courses.CertificationBadges[index].CourseId,
|
||||
courses.FeaturedBadgeCourseId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
return index;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private void ShiftProfileBadgeSelection(int delta)
|
||||
{
|
||||
if (_profileCourses.BadgeCount == 0 || delta == 0)
|
||||
return;
|
||||
_profileBadgeSelection = (_profileBadgeSelection + delta) % _profileCourses.BadgeCount;
|
||||
if (_profileBadgeSelection < 0)
|
||||
_profileBadgeSelection += _profileCourses.BadgeCount;
|
||||
var snapshot = _progressionRepository?.Snapshot() ?? default;
|
||||
var records = SongRecordRepository.Shared;
|
||||
var contexts = ProfileRatingContextBuilder.Build(
|
||||
records.CalculateRatingContexts(), _difficultyTableRegistry?.Tables,
|
||||
_libraryController?.State?.AllSongs, _activeDifficultyTable?.Id,
|
||||
snapshot, records.CalculateRating);
|
||||
var fallbackRating = snapshot.LastRatingKeyCount > 0 &&
|
||||
!string.IsNullOrWhiteSpace(snapshot.LastRatingProviderId)
|
||||
? records.CalculateRating(snapshot.LastRatingProviderId,
|
||||
snapshot.LastRatingProviderDisplayName,
|
||||
snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch)
|
||||
: records.CalculateMostRecentRatingContext(
|
||||
snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch);
|
||||
_profileSession.Refresh(snapshot, records.CalculateCollection(),
|
||||
CourseRecordRepository.Shared.CalculateCollection(_progressionRepository?.FeaturedCourseBadgeId),
|
||||
contexts, fallbackRating);
|
||||
}
|
||||
|
||||
private void SetFeaturedProfileBadge(int badgeIndex)
|
||||
{
|
||||
if (_profileCourses.BadgeCount == 0)
|
||||
_profileSession.SelectBadge(badgeIndex);
|
||||
var courseId = _profileSession.SelectedBadgeCourseId;
|
||||
if (string.IsNullOrEmpty(courseId))
|
||||
return;
|
||||
var normalized = Mathf.Clamp(badgeIndex, 0, _profileCourses.BadgeCount - 1);
|
||||
var badge = _profileCourses.CertificationBadges[normalized];
|
||||
_progressionRepository?.SetFeaturedCourseBadge(badge.CourseId);
|
||||
_profileCourses = CourseRecordRepository.Shared.CalculateCollection(badge.CourseId);
|
||||
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
|
||||
_progressionRepository?.SetFeaturedCourseBadge(courseId);
|
||||
_profileSession.RefreshCourses(CourseRecordRepository.Shared.CalculateCollection(courseId));
|
||||
}
|
||||
|
||||
private void UpdateResultsInput()
|
||||
@@ -7190,6 +6848,7 @@ namespace ProjectQuint.Bootstrap
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_updateSession.Cancel();
|
||||
CloseAudioSyncCalibration();
|
||||
FlushPendingLibraryViewState(force: true);
|
||||
_audio?.Resume();
|
||||
|
||||
@@ -8,38 +8,6 @@ using UnityEngine.Networking;
|
||||
|
||||
namespace ProjectQuint.Bootstrap
|
||||
{
|
||||
[Serializable]
|
||||
public sealed class ProjectQuintUpdateManifest
|
||||
{
|
||||
public int schemaVersion;
|
||||
public string channel = string.Empty;
|
||||
public bool available;
|
||||
public string version = string.Empty;
|
||||
public int buildNumber;
|
||||
public string publishedAt = string.Empty;
|
||||
public string downloadUrl = string.Empty;
|
||||
public string notesUrl = string.Empty;
|
||||
public string sha256 = string.Empty;
|
||||
public long sizeBytes;
|
||||
}
|
||||
|
||||
public readonly struct ProjectQuintUpdateCheckResult
|
||||
{
|
||||
public ProjectQuintUpdateCheckResult(
|
||||
bool succeeded,
|
||||
ProjectQuintUpdateManifest manifest,
|
||||
string error)
|
||||
{
|
||||
Succeeded = succeeded;
|
||||
Manifest = manifest;
|
||||
Error = error ?? string.Empty;
|
||||
}
|
||||
|
||||
public bool Succeeded { get; }
|
||||
public ProjectQuintUpdateManifest Manifest { get; }
|
||||
public string Error { get; }
|
||||
}
|
||||
|
||||
public static class ProjectQuintUpdateService
|
||||
{
|
||||
public const string ManifestUrl =
|
||||
@@ -47,7 +15,6 @@ namespace ProjectQuint.Bootstrap
|
||||
public const string DownloadPageUrl =
|
||||
"https://projectquint.duckdns.org/download/";
|
||||
public const string ExpectedHost = "projectquint.duckdns.org";
|
||||
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
|
||||
|
||||
private static readonly Regex AlphaVersionPattern = new Regex(
|
||||
@"^\d+\.\d+\.\d+-alpha\.\d+$",
|
||||
@@ -146,31 +113,6 @@ namespace ProjectQuint.Bootstrap
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
|
||||
manifest != null &&
|
||||
manifest.available &&
|
||||
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
|
||||
|
||||
public static bool ShouldOffer(
|
||||
ProjectQuintUpdateManifest manifest,
|
||||
string skippedVersion,
|
||||
bool manual) =>
|
||||
IsUpdateAvailable(manifest) &&
|
||||
(manual ||
|
||||
!string.Equals(
|
||||
manifest.version,
|
||||
skippedVersion?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
|
||||
{
|
||||
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
|
||||
return true;
|
||||
|
||||
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
|
||||
AutomaticCheckInterval;
|
||||
}
|
||||
|
||||
private static bool TryValidateNotesUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
|
||||
@@ -67,6 +67,21 @@ namespace ProjectQuint.Features.Flow
|
||||
Mode = mode;
|
||||
}
|
||||
|
||||
public bool TryBeginPlayFromHome(int activePackSelection = 0, int packCount = 0)
|
||||
{
|
||||
if (Mode != AppMode.Home)
|
||||
return false;
|
||||
if (packCount < 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(packCount));
|
||||
|
||||
PackSelection = Math.Max(0, Math.Min(activePackSelection, packCount + 1));
|
||||
Status = packCount > 0
|
||||
? $"{packCount:N0} BMS PACKS REGISTERED"
|
||||
: "SELECT A FOLDER OR ZIP TO BEGIN";
|
||||
TransitionTo(AppMode.PackSelect);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void BeginOptions()
|
||||
{
|
||||
RebindLane = -1;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a699850f7cb543659720ced37887b8b6
|
||||
folderAsset: yes
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ProjectQuint.Core.Progression;
|
||||
using ProjectQuint.Library;
|
||||
|
||||
namespace ProjectQuint.Features.Profile
|
||||
{
|
||||
internal static class ProfileRatingContextBuilder
|
||||
{
|
||||
public static IReadOnlyList<RatingSummary> Build(
|
||||
IReadOnlyList<RatingSummary> recordedContexts,
|
||||
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
|
||||
IReadOnlyList<SongLibraryEntry> activeSongs,
|
||||
string activeTableId,
|
||||
ProfileProgressSnapshot profile,
|
||||
Func<string, string, int, bool, RatingSummary> resolveRating)
|
||||
{
|
||||
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
|
||||
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
|
||||
{
|
||||
if (table == null || string.IsNullOrWhiteSpace(table.Id))
|
||||
continue;
|
||||
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
|
||||
? table.Id.Trim()
|
||||
: table.DisplayName.Trim();
|
||||
}
|
||||
|
||||
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
|
||||
return;
|
||||
var normalizedId = providerId.Trim();
|
||||
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
|
||||
if (contexts.ContainsKey(key))
|
||||
return;
|
||||
var displayName = string.IsNullOrWhiteSpace(providerName)
|
||||
? tableNames.TryGetValue(normalizedId, out var registeredName)
|
||||
? registeredName
|
||||
: normalizedId
|
||||
: providerName.Trim();
|
||||
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
|
||||
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
|
||||
}
|
||||
|
||||
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
|
||||
{
|
||||
if (!recorded.HasContext)
|
||||
continue;
|
||||
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
|
||||
}
|
||||
|
||||
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
|
||||
{
|
||||
if (table == null)
|
||||
continue;
|
||||
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
|
||||
if (preset?.DefaultKeyCount >= 4)
|
||||
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
|
||||
preset.Value.DefaultHasScratch);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(activeTableId))
|
||||
{
|
||||
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
|
||||
? registeredName
|
||||
: activeTableId;
|
||||
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
|
||||
.Where(song => song?.Metadata != null &&
|
||||
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
|
||||
song.Catalog?.HasDifficulty == true &&
|
||||
string.Equals(song.Catalog.ProviderId, activeTableId,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
|
||||
.Distinct())
|
||||
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
|
||||
}
|
||||
|
||||
AddResolved(
|
||||
profile.LastRatingProviderId,
|
||||
profile.LastRatingProviderDisplayName,
|
||||
profile.LastRatingKeyCount,
|
||||
profile.LastRatingHasScratch);
|
||||
|
||||
return contexts.Values
|
||||
.OrderByDescending(summary => summary.ActivityPlayCount)
|
||||
.ThenByDescending(summary => summary.FilledSlots)
|
||||
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(summary => summary.KeyCount)
|
||||
.ThenBy(summary => summary.HasScratch)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
|
||||
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65c9c48983064911802938aef1b8ae09
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ProjectQuint.Core.Progression;
|
||||
|
||||
namespace ProjectQuint.Features.Profile
|
||||
{
|
||||
/// <summary>Owns profile navigation independently of the input device, UI and storage.</summary>
|
||||
internal sealed class ProfileSessionController
|
||||
{
|
||||
private readonly int[] _collectionSelections = new int[3];
|
||||
public ProfileProgressSnapshot Snapshot { get; private set; }
|
||||
public ProfileCollectionSnapshot Collection { get; private set; } = ProfileCollectionSnapshot.Empty;
|
||||
public ProfileCourseSnapshot Courses { get; private set; } = ProfileCourseSnapshot.Empty;
|
||||
public IReadOnlyList<RatingSummary> RatingContexts { get; private set; } = Array.Empty<RatingSummary>();
|
||||
public RatingSummary RatingSummary { get; private set; }
|
||||
public int RatingContextIndex { get; private set; }
|
||||
public int BadgeSelection { get; private set; }
|
||||
public int CollectionColumn { get; private set; }
|
||||
public IReadOnlyList<int> CollectionSelections { get; }
|
||||
public bool IsOpen { get; private set; }
|
||||
public int Tab { get; private set; }
|
||||
public string SelectedBadgeCourseId => Courses.BadgeCount == 0
|
||||
? string.Empty : Courses.CertificationBadges[BadgeSelection].CourseId;
|
||||
|
||||
public ProfileSessionController() => CollectionSelections = Array.AsReadOnly(_collectionSelections);
|
||||
|
||||
public void Refresh(ProfileProgressSnapshot snapshot, ProfileCollectionSnapshot collection,
|
||||
ProfileCourseSnapshot courses, IReadOnlyList<RatingSummary> ratingContexts, RatingSummary fallbackRating)
|
||||
{
|
||||
Snapshot = snapshot;
|
||||
Collection = collection ?? ProfileCollectionSnapshot.Empty;
|
||||
RatingContexts = ratingContexts ?? Array.Empty<RatingSummary>();
|
||||
// Contexts are already ranked by activity. Opening the profile starts with that ranking.
|
||||
RatingContextIndex = 0;
|
||||
RatingSummary = RatingContexts.Count > 0 ? RatingContexts[0] : fallbackRating;
|
||||
RefreshCourses(courses);
|
||||
for (var column = 0; column < _collectionSelections.Length; column++)
|
||||
_collectionSelections[column] = Clamp(_collectionSelections[column], Groups(column).Count - 1);
|
||||
}
|
||||
|
||||
public void RefreshCourses(ProfileCourseSnapshot courses)
|
||||
{
|
||||
Courses = courses ?? ProfileCourseSnapshot.Empty;
|
||||
BadgeSelection = 0;
|
||||
for (var index = 0; index < Courses.BadgeCount; index++)
|
||||
if (string.Equals(Courses.CertificationBadges[index].CourseId,
|
||||
Courses.FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
BadgeSelection = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void Open(int tab)
|
||||
{
|
||||
SelectTab(tab);
|
||||
IsOpen = true;
|
||||
}
|
||||
|
||||
public void Close() => IsOpen = false;
|
||||
public void SelectTab(int tab) => Tab = Clamp(tab, 3);
|
||||
public void ShiftTab(int delta) => Tab = Wrap(Tab, delta, 4);
|
||||
|
||||
public void ShiftRatingContext(int delta)
|
||||
{
|
||||
if (RatingContexts.Count == 0)
|
||||
return;
|
||||
RatingContextIndex = Wrap(RatingContextIndex, delta, RatingContexts.Count);
|
||||
RatingSummary = RatingContexts[RatingContextIndex];
|
||||
}
|
||||
|
||||
public void ShiftCollectionColumn(int delta) => CollectionColumn = Wrap(CollectionColumn, delta, 3);
|
||||
|
||||
public void ShiftCollectionSelection(int delta) =>
|
||||
_collectionSelections[CollectionColumn] = Wrap(
|
||||
_collectionSelections[CollectionColumn], delta, Groups(CollectionColumn).Count);
|
||||
|
||||
public void SelectCollection(int column, int index)
|
||||
{
|
||||
CollectionColumn = Clamp(column, 2);
|
||||
_collectionSelections[CollectionColumn] = Clamp(index, Groups(CollectionColumn).Count - 1);
|
||||
}
|
||||
|
||||
public void SelectBadge(int index) => BadgeSelection = Clamp(index, Courses.BadgeCount - 1);
|
||||
public void ShiftBadge(int delta) => BadgeSelection = Wrap(BadgeSelection, delta, Courses.BadgeCount);
|
||||
|
||||
private IReadOnlyList<CollectionGroupSummary> Groups(int column) => column switch
|
||||
{
|
||||
0 => Collection.Packs,
|
||||
1 => Collection.KeyModes,
|
||||
_ => Collection.Difficulties,
|
||||
};
|
||||
|
||||
private static int Clamp(int value, int maximum) => Math.Max(0, Math.Min(value, maximum));
|
||||
private static int Wrap(int value, int delta, int count) => count <= 0
|
||||
? 0 : (int)((((long)value + delta) % count + count) % count);
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void SetPreviewSnapshot(ProfileProgressSnapshot snapshot) => Snapshot = snapshot;
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d53feec096341b8a67e3bf4404774dd
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fee265429b204c6fbc7524f90b8c438d
|
||||
folderAsset: yes
|
||||
@@ -0,0 +1,131 @@
|
||||
using ProjectQuint.Core.Release;
|
||||
using ProjectQuint.Features.Flow;
|
||||
|
||||
namespace ProjectQuint.Features.Updates
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns one update-check session. A generation prevents an abandoned network request
|
||||
/// from reopening a dismissed modal or overwriting a newer manual check.
|
||||
/// Transport, persistence and installer invocation remain at the application boundary.
|
||||
/// </summary>
|
||||
internal sealed class UpdateSessionController
|
||||
{
|
||||
private int _generation;
|
||||
private bool _manual;
|
||||
private bool _automaticCheckStarted;
|
||||
private bool _offerPending;
|
||||
|
||||
public bool IsChecking { get; private set; }
|
||||
public bool IsModalOpen { get; private set; }
|
||||
public int Selection { get; private set; }
|
||||
public string Status { get; private set; } = "NOT CHECKED";
|
||||
public string Error { get; private set; } = string.Empty;
|
||||
public ProjectQuintUpdateManifest Manifest { get; private set; }
|
||||
public long LastSuccessfulCheckUtcTicks { get; private set; }
|
||||
|
||||
public bool TryBeginAutomaticCheck(AppMode mode, bool isEditor,
|
||||
long lastSuccessfulCheckUtcTicks, long nowUtcTicks, out int generation)
|
||||
{
|
||||
generation = 0;
|
||||
if (_automaticCheckStarted || mode != AppMode.Home)
|
||||
return false;
|
||||
_automaticCheckStarted = true;
|
||||
if (isEditor)
|
||||
return false;
|
||||
if (!UpdatePolicy.ShouldCheckAutomatically(lastSuccessfulCheckUtcTicks, nowUtcTicks))
|
||||
{
|
||||
Status = "CHECKED RECENTLY";
|
||||
return false;
|
||||
}
|
||||
return TryBeginCheck(false, out generation);
|
||||
}
|
||||
|
||||
public bool TryBeginCheck(bool manual, out int generation)
|
||||
{
|
||||
generation = _generation;
|
||||
if (IsChecking)
|
||||
return false;
|
||||
generation = ++_generation;
|
||||
_manual = manual;
|
||||
IsChecking = true;
|
||||
IsModalOpen = false;
|
||||
_offerPending = false;
|
||||
Manifest = null;
|
||||
Error = string.Empty;
|
||||
Status = "CHECKING...";
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool CompleteCheck(int generation, ProjectQuintUpdateCheckResult result,
|
||||
string skippedVersion, long nowUtcTicks)
|
||||
{
|
||||
if (!IsChecking || generation != _generation)
|
||||
return false;
|
||||
IsChecking = false;
|
||||
if (!result.Succeeded || result.Manifest == null)
|
||||
{
|
||||
Error = result.Succeeded ? "UPDATE MANIFEST IS MISSING" : result.Error;
|
||||
Status = _manual ? "CHECK FAILED" : "NOT CHECKED";
|
||||
return true;
|
||||
}
|
||||
|
||||
LastSuccessfulCheckUtcTicks = nowUtcTicks;
|
||||
Manifest = result.Manifest;
|
||||
Status = UpdatePolicy.IsUpdateAvailable(Manifest)
|
||||
? $"AVAILABLE // {Manifest.version.ToUpperInvariant()}"
|
||||
: $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}";
|
||||
_offerPending = UpdatePolicy.ShouldOffer(Manifest, skippedVersion, _manual);
|
||||
return true;
|
||||
}
|
||||
|
||||
public bool TryPresent(AppMode mode, bool blocked)
|
||||
{
|
||||
if (blocked || IsChecking || !_offerPending || IsModalOpen || Manifest == null ||
|
||||
(mode != AppMode.Home && mode != AppMode.Options))
|
||||
return false;
|
||||
_offerPending = false;
|
||||
Selection = 0;
|
||||
IsModalOpen = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public void MoveSelection(int delta)
|
||||
{
|
||||
if (IsModalOpen)
|
||||
Selection = ((Selection + delta) % 3 + 3) % 3;
|
||||
}
|
||||
|
||||
public void MarkInstallerOpening() => Status = "OPENING SECURE UPDATE INSTALLER...";
|
||||
|
||||
public void Dismiss()
|
||||
{
|
||||
IsModalOpen = false;
|
||||
_offerPending = false;
|
||||
}
|
||||
|
||||
public void AbortCheck(int generation)
|
||||
{
|
||||
if (IsChecking && generation == _generation)
|
||||
Cancel();
|
||||
}
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
++_generation;
|
||||
if (IsChecking)
|
||||
Status = "CHECK CANCELLED";
|
||||
IsChecking = false;
|
||||
Dismiss();
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
public void ShowPreview(ProjectQuintUpdateManifest manifest)
|
||||
{
|
||||
Cancel();
|
||||
Manifest = manifest;
|
||||
Selection = 0;
|
||||
IsModalOpen = true;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3f678ce98b05486bac254d968c77eddf
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9a1a356268ef4303b32df7e8fdc9e78f
|
||||
folderAsset: yes
|
||||
@@ -0,0 +1,468 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Bms;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Course;
|
||||
using ProjectQuint.Core.Gauge;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class BmsParsingTests
|
||||
{
|
||||
[Test]
|
||||
public void Parser_DecodesShiftJisJapaneseMetadata()
|
||||
{
|
||||
var directory = Path.Combine(Path.GetTempPath(), "ProjectQuint-ShiftJis-" + Guid.NewGuid().ToString("N"));
|
||||
var path = Path.Combine(directory, "chart.bms");
|
||||
Directory.CreateDirectory(directory);
|
||||
try
|
||||
{
|
||||
var bytes = new List<byte>();
|
||||
bytes.AddRange(Encoding.ASCII.GetBytes("#TITLE PUPA\r\n#ARTIST "));
|
||||
bytes.AddRange(new byte[]
|
||||
{
|
||||
0x83, 0x82, 0x83, 0x8A, 0x83, 0x82, 0x83, 0x8A,
|
||||
0x82, 0xA0, 0x82, 0xC2, 0x82, 0xB5,
|
||||
});
|
||||
bytes.AddRange(Encoding.ASCII.GetBytes("\r\n#BPM 202\r\n"));
|
||||
File.WriteAllBytes(path, bytes.ToArray());
|
||||
|
||||
var metadata = BmsParser.ReadMetadataFile(path);
|
||||
|
||||
Assert.That(metadata.Title, Is.EqualTo("PUPA"));
|
||||
Assert.That(metadata.Artist, Is.EqualTo("モリモリあつし"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
Directory.Delete(directory, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_Base62PreservesCaseSensitiveObjectIdsAcrossAllTimelines()
|
||||
{
|
||||
const string source = @"
|
||||
#TITLE Base62 Test
|
||||
#BPM 120
|
||||
#WAVAA upper.wav
|
||||
#WAVAa lower.wav
|
||||
#BMPBA upper.png
|
||||
#BMPBa lower.png
|
||||
#BPMCA 150
|
||||
#BPMCa 180
|
||||
#STOPDA 48
|
||||
#STOPDa 96
|
||||
#SCROLLEA 1.25
|
||||
#SCROLLEa 0.5
|
||||
#LNOBJ Aa
|
||||
#00101:AAAa
|
||||
#00104:BABa
|
||||
#00108:CACa
|
||||
#00109:DADa
|
||||
#001SC:EAEa
|
||||
#00211:AAAAAa
|
||||
#RANDOM 1
|
||||
#IF 1
|
||||
#BASE 62
|
||||
#ENDIF
|
||||
#ENDRANDOM
|
||||
";
|
||||
|
||||
var document = BmsParser.ParseText("base62.bms", source);
|
||||
var compiled = BmsTimelineCompiler.Compile(document);
|
||||
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
||||
|
||||
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(62));
|
||||
Assert.That(document.WavDefinitions, Has.Count.EqualTo(2));
|
||||
Assert.That(document.WavDefinitions["AA"], Is.EqualTo("upper.wav"));
|
||||
Assert.That(document.WavDefinitions["Aa"], Is.EqualTo("lower.wav"));
|
||||
Assert.That(document.BmpDefinitions["BA"], Is.EqualTo("upper.png"));
|
||||
Assert.That(document.BmpDefinitions["Ba"], Is.EqualTo("lower.png"));
|
||||
Assert.That(document.ExtendedBpms.Keys, Is.EquivalentTo(new[] { "CA", "Ca" }));
|
||||
Assert.That(document.Stops.Keys, Is.EquivalentTo(new[] { "DA", "Da" }));
|
||||
Assert.That(document.Scrolls.Keys, Is.EquivalentTo(new[] { "EA", "Ea" }));
|
||||
Assert.That(document.Metadata.LnObj, Is.EqualTo("Aa"));
|
||||
|
||||
Assert.That(chart.AudioResources, Has.Count.EqualTo(2));
|
||||
Assert.That(chart.AudioResources["AA"], Is.EqualTo("upper.wav"));
|
||||
Assert.That(chart.AudioResources["Aa"], Is.EqualTo("lower.wav"));
|
||||
Assert.That(chart.BackgroundAudioEvents.Select(item => item.SoundId),
|
||||
Is.EqualTo(new[] { "AA", "Aa" }));
|
||||
Assert.That(chart.VisualEvents.Select(item => item.ResourceId),
|
||||
Is.EqualTo(new[] { "BA", "Ba" }));
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
||||
Assert.That(chart.Notes.Count(item => item.IsLong), Is.EqualTo(1));
|
||||
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("Aa"));
|
||||
Assert.That(chart.Warnings.Any(item => item.StartsWith("Undefined", StringComparison.Ordinal)), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_Base36NormalizesLowercaseObjectIdsEvenWhenBaseIsDeclaredLast()
|
||||
{
|
||||
const string source = @"
|
||||
#BPM 120
|
||||
#WAVAa hit.wav
|
||||
#BMPbB image.png
|
||||
#LNOBJ cC
|
||||
#00111:AaAaCc
|
||||
#00104:bB
|
||||
#BASE 36
|
||||
";
|
||||
|
||||
var document = BmsParser.ParseText("base36-lowercase.bms", source);
|
||||
var chart = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(36));
|
||||
Assert.That(document.WavDefinitions.Keys, Is.EqualTo(new[] { "AA" }));
|
||||
Assert.That(document.BmpDefinitions.Keys, Is.EqualTo(new[] { "BB" }));
|
||||
Assert.That(document.Metadata.LnObj, Is.EqualTo("CC"));
|
||||
Assert.That(document.Events.All(item => item.ObjectId == item.ObjectId.ToUpperInvariant()), Is.True);
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
||||
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("CC"));
|
||||
Assert.That(chart.BgaEvents.Single().ResourceId, Is.EqualTo("BB"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_RejectsUnknownObjectBaseInsteadOfSilentlyCorruptingIds()
|
||||
{
|
||||
const string source = "#BASE 64\n#WAVAA hit.wav\n#00111:AA";
|
||||
|
||||
Assert.That(
|
||||
() => BmsParser.ParseText("unsupported-base.bms", source),
|
||||
Throws.TypeOf<FormatException>()
|
||||
.With.Message.Contains("supports only #BASE 36 and #BASE 62"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_RejectsObsoleteLnTypeTwoInsteadOfMisreadingLongNotes()
|
||||
{
|
||||
const string source = "#LNTYPE 2\n#BPM 120\n#00151:0001010100";
|
||||
|
||||
Assert.That(
|
||||
() => BmsParser.ParseText("obsolete-mgq-ln.bms", source),
|
||||
Throws.TypeOf<FormatException>()
|
||||
.With.Message.Contains("Unsupported BMS #LNTYPE 2")
|
||||
.And.Message.Contains("#LNTYPE 1 and #LNOBJ"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_AcceptsSupportedLnTypeOne()
|
||||
{
|
||||
const string source = "#LNTYPE 1\n#BPM 120\n#00151:00010001";
|
||||
|
||||
var document = BmsParser.ParseText("standard-ln.bms", source);
|
||||
var chart = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(document.Metadata.LnType, Is.EqualTo(1));
|
||||
Assert.That(chart.Notes.Count(note => note.IsLong), Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Converter_PreservesScratchAsFixedLeftLane()
|
||||
{
|
||||
const string source = @"
|
||||
#TITLE Smoke Test
|
||||
#ARTIST ProjectQuint
|
||||
#BPM 120
|
||||
#WAV01 kick.wav
|
||||
#00111:01
|
||||
#00112:0001
|
||||
#00116:01
|
||||
";
|
||||
|
||||
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("smoke.bms", source));
|
||||
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
||||
|
||||
Assert.That(chart.Metadata.Title, Is.EqualTo("Smoke Test"));
|
||||
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(5));
|
||||
Assert.That(chart.Metadata.HasScratch, Is.True);
|
||||
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(6));
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(3));
|
||||
Assert.That(chart.Notes[0].Lane, Is.EqualTo(0));
|
||||
Assert.That(chart.Notes[0].IsScratch, Is.True);
|
||||
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
||||
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(1));
|
||||
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(2));
|
||||
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Parser_PreservesDeclaredTotalForGaugeCalculation()
|
||||
{
|
||||
const string source = @"
|
||||
#TITLE Gauge Test
|
||||
#BPM 120
|
||||
#TOTAL 470.5
|
||||
#00111:01
|
||||
";
|
||||
|
||||
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("total.bms", source));
|
||||
|
||||
Assert.That(chart.Metadata.Total, Is.EqualTo(470.5).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LaneModifier_MirrorReversesAllFiveLanes()
|
||||
{
|
||||
var laneMap = LanePermutation.Create(LaneModifier.Mirror);
|
||||
|
||||
Assert.That(laneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LaneModifier_MirrorKeepsScratchFixedAndReversesSevenKeys()
|
||||
{
|
||||
var laneMap = LanePermutation.Create(LaneModifier.Mirror, keyCount: 7, hasScratch: true);
|
||||
|
||||
Assert.That(laneMap, Is.EqualTo(new[] { 0, 7, 6, 5, 4, 3, 2, 1 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParserAndConverter_SupportOnePlusSevenAsEightGameplayLanes()
|
||||
{
|
||||
const string source = @"
|
||||
#TITLE 1+7 Test
|
||||
#BPM 120
|
||||
#WAV01 hit.wav
|
||||
#00111:01
|
||||
#00112:01
|
||||
#00113:01
|
||||
#00114:01
|
||||
#00115:01
|
||||
#00118:01
|
||||
#00119:01
|
||||
#00116:01
|
||||
";
|
||||
|
||||
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("one-plus-seven.bms", source));
|
||||
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
|
||||
|
||||
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(7));
|
||||
Assert.That(chart.Metadata.HasScratch, Is.True);
|
||||
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(8));
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(8));
|
||||
Assert.That(chart.Notes.Select(note => note.Lane), Is.EquivalentTo(Enumerable.Range(0, 8)));
|
||||
Assert.That(chart.Notes.Single(note => note.IsScratch).Lane, Is.Zero);
|
||||
}
|
||||
|
||||
[TestCase(7, false, "7K")]
|
||||
[TestCase(7, true, "1+7K")]
|
||||
[TestCase(5, false, "5K")]
|
||||
[TestCase(5, true, "1+5K")]
|
||||
public void KeyModeLabel_DistinguishesKeysFromScratchAndKeys(
|
||||
int keyCount,
|
||||
bool hasScratch,
|
||||
string expected)
|
||||
{
|
||||
Assert.That(
|
||||
ChartKeyMode.FormatLabel(keyCount, hasScratch),
|
||||
Is.EqualTo(expected));
|
||||
}
|
||||
|
||||
[TestCase("#00111:01\n#00112:01\n#00114:01\n#00115:01", 4)]
|
||||
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01", 5)]
|
||||
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00115:01\n#00118:01\n#00119:01", 6)]
|
||||
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01\n#00118:01\n#00119:01", 7)]
|
||||
[TestCase("#00151:0102\n#00152:0102\n#00154:0102\n#00155:0102", 4)]
|
||||
public void Parser_DetectsSupportedKeyLayouts(string events, int expectedKeyCount)
|
||||
{
|
||||
var document = BmsParser.ParseText(
|
||||
"layout.bms",
|
||||
"#BPM 120\n#WAV01 hit.wav\n#WAV02 tail.wav\n" + events);
|
||||
var chart = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(expectedKeyCount));
|
||||
Assert.That(chart.Notes.Select(note => note.Lane).Distinct().Count(), Is.EqualTo(expectedKeyCount));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SixKeyContract_SkipsCenterChannelAndKeepsRightmostChannel()
|
||||
{
|
||||
var document = BmsParser.ParseText(
|
||||
"six-key-with-center-stray.bms",
|
||||
"#BPM 120\n" +
|
||||
"#WAV01 one.wav\n#WAV02 two.wav\n#WAV03 three.wav\n" +
|
||||
"#WAV04 stray.wav\n#WAV05 five.wav\n#WAV06 six.wav\n#WAV07 seven.wav\n" +
|
||||
"#00111:01\n#00112:02\n#00113:03\n#00114:04\n" +
|
||||
"#00115:05\n#00118:06\n#00119:07\n");
|
||||
document.Metadata.KeyCount = 6;
|
||||
|
||||
var chart = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(6));
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(6));
|
||||
Assert.That(chart.Notes.Select(note => note.Lane),
|
||||
Is.EquivalentTo(Enumerable.Range(0, 6)));
|
||||
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Contain("07"));
|
||||
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Not.Contain("04"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LaneModifier_RandomCreatesOneDeterministicPermutationPerPlay()
|
||||
{
|
||||
var first = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
|
||||
var second = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
|
||||
|
||||
Assert.That(first, Is.EqualTo(second));
|
||||
Assert.That(first, Is.EquivalentTo(new[] { 0, 1, 2, 3, 4 }));
|
||||
Assert.That(first, Is.Not.EqualTo(new[] { 0, 1, 2, 3, 4 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayableChartConverter_AppliesTheSameMappingWithoutMutatingSourceNotes()
|
||||
{
|
||||
var notes = Enumerable.Range(0, 5)
|
||||
.Select(lane => new BmsNote { Id = lane, Lane = lane })
|
||||
.ToArray();
|
||||
var source = new BmsChart();
|
||||
source.Notes.AddRange(notes);
|
||||
|
||||
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Random, randomSeed: 7);
|
||||
|
||||
for (var lane = 0; lane < notes.Length; lane++)
|
||||
{
|
||||
Assert.That(conversion.Chart.Notes.Single(note => note.Id == lane).Lane,
|
||||
Is.EqualTo(conversion.LaneMap[lane]));
|
||||
Assert.That(notes[lane].Lane, Is.EqualTo(lane));
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void InvisibleBmsChannels_BecomeModifierAwareLaneSoundEvents()
|
||||
{
|
||||
var document = BmsParser.ParseText(
|
||||
"invisible.bms",
|
||||
"#BPM 120\n#WAV01 visible.wav\n#WAV02 empty.wav\n#00111:01\n#00231:02");
|
||||
var source = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(source.LaneSoundEvents, Has.Count.EqualTo(1));
|
||||
Assert.That(source.LaneSoundEvents[0].Lane, Is.Zero);
|
||||
Assert.That(source.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
|
||||
|
||||
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror);
|
||||
Assert.That(conversion.Chart.LaneSoundEvents, Has.Count.EqualTo(1));
|
||||
Assert.That(conversion.Chart.LaneSoundEvents[0].Lane, Is.EqualTo(4));
|
||||
Assert.That(conversion.Chart.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayableChartConverter_PreservesLongNoteIdentityTimingAndResourceTimelines()
|
||||
{
|
||||
var source = new BmsChart
|
||||
{
|
||||
Metadata = new BmsMetadata
|
||||
{
|
||||
ChartPath = "/songs/example/chart.bms",
|
||||
Title = "Example",
|
||||
Artist = "Composer",
|
||||
StageFile = "stage.png",
|
||||
Total = 420,
|
||||
},
|
||||
};
|
||||
source.WavDefinitions["01"] = "head.wav";
|
||||
source.BmpDefinitions["02"] = "movie.mp4";
|
||||
source.Notes.Add(new BmsNote
|
||||
{
|
||||
Id = 42,
|
||||
Lane = 1,
|
||||
StartTime = 1.25,
|
||||
EndTime = 3.5,
|
||||
SoundId = "01",
|
||||
TailSoundId = "09",
|
||||
});
|
||||
source.BackgroundSounds.Add(new BmsAudioEvent { Time = 0.5, SoundId = "01" });
|
||||
source.BgaEvents.Add(new BmsBgaEvent
|
||||
{
|
||||
Time = 0.75,
|
||||
ResourceId = "02",
|
||||
Layer = BmsBgaLayer.Layer,
|
||||
});
|
||||
|
||||
var chart = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror).Chart;
|
||||
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(1));
|
||||
Assert.That(chart.Notes[0].Id, Is.EqualTo(42));
|
||||
Assert.That(chart.Notes[0].Lane, Is.EqualTo(3));
|
||||
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(1.25));
|
||||
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.5));
|
||||
Assert.That(chart.Notes[0].SoundId, Is.EqualTo("01"));
|
||||
Assert.That(chart.Notes[0].TailSoundId, Is.EqualTo("09"));
|
||||
Assert.That(chart.Metadata.SourcePath, Is.EqualTo("/songs/example/chart.bms"));
|
||||
Assert.That(chart.Metadata.GaugeTotal, Is.EqualTo(420));
|
||||
Assert.That(chart.AudioResources["01"], Is.EqualTo("head.wav"));
|
||||
Assert.That(chart.VisualResources["02"], Is.EqualTo("movie.mp4"));
|
||||
Assert.That(chart.BackgroundAudioEvents, Has.Count.EqualTo(1));
|
||||
Assert.That(chart.VisualEvents[0].Layer, Is.EqualTo(PlayableVisualLayer.Overlay));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayableChartConverter_PreservesScratchSoundAndDuration()
|
||||
{
|
||||
var source = new BmsChart
|
||||
{
|
||||
Metadata = new BmsMetadata { KeyCount = 5, HasScratch = true },
|
||||
ScratchObjectCount = 1,
|
||||
Duration = 102.0,
|
||||
};
|
||||
source.Notes.Add(new BmsNote
|
||||
{
|
||||
Id = 1,
|
||||
Lane = 1,
|
||||
StartTime = 1.0,
|
||||
EndTime = 1.0,
|
||||
SoundId = "KEY",
|
||||
});
|
||||
source.Notes.Add(new BmsNote
|
||||
{
|
||||
Id = 2,
|
||||
Lane = 0,
|
||||
StartTime = 100.0,
|
||||
EndTime = 100.0,
|
||||
SoundId = "SCRATCH",
|
||||
IsScratch = true,
|
||||
});
|
||||
|
||||
var chart = BmsPlayableChartConverter.Convert(source).Chart;
|
||||
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(2));
|
||||
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("KEY"));
|
||||
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("SCRATCH"));
|
||||
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
|
||||
Assert.That(chart.Duration, Is.EqualTo(102.0));
|
||||
Assert.That(source.Notes, Has.Count.EqualTo(2));
|
||||
Assert.That(source.Duration, Is.EqualTo(102.0));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ParserAndTimeline_CompileBmsScrollWithoutChangingJudgementTime()
|
||||
{
|
||||
const string source = @"
|
||||
#TITLE Scroll Test
|
||||
#BPM 120
|
||||
#SCROLL01 0.5
|
||||
#SCROLL02 2.0
|
||||
#001SC:0102
|
||||
#00111:0101
|
||||
";
|
||||
|
||||
var document = BmsParser.ParseText("scroll.bms", source);
|
||||
var chart = BmsTimelineCompiler.Compile(document);
|
||||
|
||||
Assert.That(document.Scrolls["01"], Is.EqualTo(0.5));
|
||||
Assert.That(document.Scrolls["02"], Is.EqualTo(2.0));
|
||||
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
||||
Assert.That(chart.Notes[1].StartTime, Is.EqualTo(3.0).Within(0.000001));
|
||||
Assert.That(chart.ScrollMap.PositionAtBeat(4), Is.EqualTo(4).Within(0.000001));
|
||||
Assert.That(chart.ScrollMap.PositionAtBeat(6), Is.EqualTo(5).Within(0.000001));
|
||||
Assert.That(chart.ScrollMap.PositionAtBeat(8), Is.EqualTo(9).Within(0.000001));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 11e00ecd33ce4a44b4ba5ae35921a2b9
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 551153b8a58bd4ddd8daf9dd5dcde978
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff984ac8f483b429aace8d868437ea96
|
||||
@@ -0,0 +1,280 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Bms;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Course;
|
||||
using ProjectQuint.Core.Gauge;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class GaugeTests
|
||||
{
|
||||
[Test]
|
||||
public void PlayGauge_NormalUsesTotalPerNoteAndSeventyFivePercentBorder()
|
||||
{
|
||||
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 100, totalNotes: 100);
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(20));
|
||||
Assert.That(gauge.IsCleared, Is.False);
|
||||
|
||||
for (var index = 0; index < 55; index++)
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(75).Within(0.000001));
|
||||
Assert.That(gauge.IsCleared, Is.True);
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(69).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_CourseModeCarriesValueAndAppliesLr2DamageReduction()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.Class,
|
||||
declaredTotal: 0,
|
||||
totalNotes: 100,
|
||||
restoredValue: 29.0);
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(27.2).Within(0.000001));
|
||||
gauge.ApplyEmptyInputPenalty();
|
||||
Assert.That(gauge.Value, Is.EqualTo(26.0).Within(0.000001));
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
Assert.That(gauge.Value, Is.EqualTo(26.1).Within(0.000001));
|
||||
Assert.That(gauge.IsCleared, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_FiveKeyCourseProfileUsesConfiguredDeltas()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.Class,
|
||||
declaredTotal: 0,
|
||||
totalNotes: 100,
|
||||
restoredValue: 50.0,
|
||||
courseGaugeProfile: CourseGaugeProfile.FiveKey);
|
||||
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
gauge.Apply(JudgementGrade.Good);
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
gauge.ApplyEmptyInputPenalty();
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(48.51).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_SevenKeyCourseProfileUsesThresholdDamageReduction()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.Class,
|
||||
declaredTotal: 0,
|
||||
totalNotes: 100,
|
||||
restoredValue: 24.0,
|
||||
courseGaugeProfile: CourseGaugeProfile.SevenKey);
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(21.6).Within(0.000001));
|
||||
gauge.Apply(JudgementGrade.Cool);
|
||||
Assert.That(gauge.Value, Is.EqualTo(21.72).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_NormalNeverDropsBelowTwoPercent()
|
||||
{
|
||||
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 300, totalNotes: 100);
|
||||
|
||||
for (var index = 0; index < 20; index++)
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(2));
|
||||
Assert.That(gauge.IsFailed, Is.False);
|
||||
Assert.That(gauge.IsCleared, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_HardRecoversAndUsesLowGaugeDamageReduction()
|
||||
{
|
||||
var gauge = new PlayGauge(GaugeMode.Hard, declaredTotal: 300, totalNotes: 100);
|
||||
|
||||
for (var index = 0; index < 6; index++)
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(40));
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(32));
|
||||
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
gauge.Apply(JudgementGrade.Cool);
|
||||
gauge.Apply(JudgementGrade.Good);
|
||||
Assert.That(gauge.Value, Is.EqualTo(32.3).Within(0.000001));
|
||||
Assert.That(gauge.IsFailed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_StandardHardRecoversAndReducesDamageAtLowGauge()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.Hard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100,
|
||||
restoredValue: 49.0);
|
||||
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
Assert.That(gauge.Value, Is.EqualTo(49.15).Within(0.000001));
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(41.15).Within(0.000001));
|
||||
Assert.That(gauge.IsFailed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_StandardHardSurvivesTenConsecutiveMisses()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.Hard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100);
|
||||
|
||||
for (var index = 0; index < 10; index++)
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(14.0).Within(0.000001));
|
||||
Assert.That(gauge.IsFailed, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_ExHardUsesReducedRecoveryAndNoLowGaugeDamageReduction()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.ExHard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100,
|
||||
restoredValue: 50.0);
|
||||
|
||||
gauge.Apply(JudgementGrade.Kool);
|
||||
gauge.Apply(JudgementGrade.Cool);
|
||||
gauge.Apply(JudgementGrade.Good);
|
||||
Assert.That(gauge.Value, Is.EqualTo(50.21).Within(0.000001));
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.EqualTo(34.21).Within(0.000001));
|
||||
gauge.ApplyEmptyInputPenalty();
|
||||
Assert.That(gauge.Value, Is.EqualTo(26.21).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_ExHardFailsOnSeventhConsecutiveMiss()
|
||||
{
|
||||
var gauge = new PlayGauge(
|
||||
GaugeMode.ExHard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100);
|
||||
|
||||
for (var index = 0; index < 6; index++)
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
|
||||
Assert.That(gauge.Value, Is.EqualTo(4.0).Within(0.000001));
|
||||
Assert.That(gauge.IsFailed, Is.False);
|
||||
|
||||
gauge.Apply(JudgementGrade.Miss);
|
||||
Assert.That(gauge.Value, Is.Zero);
|
||||
Assert.That(gauge.IsFailed, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GaugeFallbackChain_UsesExactParallelHardAndNormalStates()
|
||||
{
|
||||
var chain = new GaugeFallbackChain(
|
||||
GaugeMode.ExHard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100,
|
||||
fallbackEnabled: true);
|
||||
var hardReference = new PlayGauge(GaugeMode.Hard, 300, 100);
|
||||
var normalReference = new PlayGauge(GaugeMode.Normal, 300, 100);
|
||||
|
||||
GaugeTransition transition = default;
|
||||
for (var index = 0; index < 7; index++)
|
||||
{
|
||||
transition = chain.Apply(JudgementGrade.Miss);
|
||||
hardReference.Apply(JudgementGrade.Miss);
|
||||
normalReference.Apply(JudgementGrade.Miss);
|
||||
}
|
||||
|
||||
Assert.That(transition.Changed, Is.True);
|
||||
Assert.That(transition.From, Is.EqualTo(GaugeMode.ExHard));
|
||||
Assert.That(transition.To, Is.EqualTo(GaugeMode.Hard));
|
||||
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Hard));
|
||||
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(hardReference.Value).Within(0.000001));
|
||||
|
||||
while (chain.ActiveGauge.Mode == GaugeMode.Hard)
|
||||
{
|
||||
transition = chain.Apply(JudgementGrade.Miss);
|
||||
hardReference.Apply(JudgementGrade.Miss);
|
||||
normalReference.Apply(JudgementGrade.Miss);
|
||||
}
|
||||
|
||||
Assert.That(transition.Changed, Is.True);
|
||||
Assert.That(transition.From, Is.EqualTo(GaugeMode.Hard));
|
||||
Assert.That(transition.To, Is.EqualTo(GaugeMode.Normal));
|
||||
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
|
||||
|
||||
for (var index = 0; index < 25; index++)
|
||||
{
|
||||
chain.Apply(JudgementGrade.Kool);
|
||||
normalReference.Apply(JudgementGrade.Kool);
|
||||
}
|
||||
|
||||
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Normal));
|
||||
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
|
||||
Assert.That(chain.ActiveGauge.IsCleared, Is.True);
|
||||
}
|
||||
|
||||
[TestCase(GaugeMode.Class)]
|
||||
[TestCase(GaugeMode.Normal)]
|
||||
public void GaugeFallbackChain_DoesNotCreateUnsupportedFallbacks(GaugeMode mode)
|
||||
{
|
||||
var chain = new GaugeFallbackChain(
|
||||
mode,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100,
|
||||
fallbackEnabled: true);
|
||||
|
||||
Assert.That(chain.GaugeCount, Is.EqualTo(1));
|
||||
Assert.That(chain.HasFallback, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void GaugeFallbackChain_DisabledExHardStillFailsWithoutTransition()
|
||||
{
|
||||
var chain = new GaugeFallbackChain(
|
||||
GaugeMode.ExHard,
|
||||
declaredTotal: 300,
|
||||
totalNotes: 100,
|
||||
fallbackEnabled: false);
|
||||
|
||||
GaugeTransition transition = default;
|
||||
for (var index = 0; index < 7; index++)
|
||||
transition = chain.Apply(JudgementGrade.Miss);
|
||||
|
||||
Assert.That(transition.Changed, Is.False);
|
||||
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.ExHard));
|
||||
Assert.That(chain.ActiveGauge.IsFailed, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayGauge_UsesOfficialDefaultTotalWhenHeaderIsMissing()
|
||||
{
|
||||
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 0, totalNotes: 1000);
|
||||
|
||||
Assert.That(gauge.Total, Is.EqualTo(7.605 * 1000 / 16.5).Within(0.000001));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 443a38441d2c477db7e4983a4b938f83
|
||||
@@ -0,0 +1,263 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Bms;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Course;
|
||||
using ProjectQuint.Core.Gauge;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class MusicalTimingTests
|
||||
{
|
||||
[Test]
|
||||
public void Compiler_PreservesBmpDefinitionsAndBgaLayerEvents()
|
||||
{
|
||||
const string source = @"
|
||||
#BPM 120
|
||||
#BMP01 movie.mpg
|
||||
#BMP02 poor.png
|
||||
#BMP03 layer.png
|
||||
#00104:01
|
||||
#00106:02
|
||||
#00107:03
|
||||
";
|
||||
|
||||
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("bga.bms", source));
|
||||
|
||||
Assert.That(chart.BmpDefinitions["01"], Is.EqualTo("movie.mpg"));
|
||||
Assert.That(chart.BgaEvents, Has.Count.EqualTo(3));
|
||||
Assert.That(chart.BgaEvents[0].Time, Is.EqualTo(2.0).Within(0.000001));
|
||||
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Base));
|
||||
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Poor));
|
||||
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Layer));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Compiler_PairsLongNoteChannelWithoutTailJudgement()
|
||||
{
|
||||
const string source = @"
|
||||
#BPM 120
|
||||
#WAV01 head.wav
|
||||
#WAV02 tail.wav
|
||||
#00151:0102
|
||||
";
|
||||
|
||||
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("long.bms", source));
|
||||
|
||||
Assert.That(chart.Notes, Has.Count.EqualTo(1));
|
||||
Assert.That(chart.Notes[0].IsLong, Is.True);
|
||||
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
|
||||
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.0).Within(0.000001));
|
||||
}
|
||||
|
||||
[TestCase(120.0, 0.125)]
|
||||
[TestCase(150.0, 0.100)]
|
||||
[TestCase(180.0, 0.0833333333333333)]
|
||||
public void LongNoteMusicalSixteenths_FollowTempo(double bpm, double expectedInterval)
|
||||
{
|
||||
var tempoMap = TempoMap.Constant(bpm);
|
||||
var note = new PlayableNote
|
||||
{
|
||||
Id = 1,
|
||||
Lane = 0,
|
||||
StartTime = 0,
|
||||
EndTime = tempoMap.BeatToTime(1.0),
|
||||
StartBeat = 0,
|
||||
EndBeat = 1.0,
|
||||
};
|
||||
|
||||
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
|
||||
|
||||
Assert.That(ticks, Has.Length.EqualTo(4));
|
||||
Assert.That(ticks[0], Is.EqualTo(expectedInterval).Within(0.000001));
|
||||
for (var index = 1; index < ticks.Length; index++)
|
||||
Assert.That(ticks[index] - ticks[index - 1],
|
||||
Is.EqualTo(expectedInterval).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Compiler_PreservesMeasureLengthTempoPriorityAndStopInTempoMap()
|
||||
{
|
||||
const string source = @"
|
||||
#BPM 120
|
||||
#BPM01 180
|
||||
#STOP01 48
|
||||
#00002:0.5
|
||||
#00003:0078
|
||||
#00008:0001
|
||||
#00009:0001
|
||||
#00011:0001
|
||||
#00112:01
|
||||
";
|
||||
|
||||
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("timing.bms", source));
|
||||
var stopNote = chart.Notes.Single(note => note.Lane == 0);
|
||||
var nextMeasureNote = chart.Notes.Single(note => note.Lane == 1);
|
||||
|
||||
Assert.That(stopNote.StartBeat, Is.EqualTo(1.0).Within(0.000001));
|
||||
Assert.That(stopNote.StartTime, Is.EqualTo(0.5).Within(0.000001),
|
||||
"Objects at a STOP position are judged before the STOP duration.");
|
||||
Assert.That(nextMeasureNote.StartBeat, Is.EqualTo(2.0).Within(0.000001));
|
||||
Assert.That(nextMeasureNote.StartTime, Is.EqualTo(7.0 / 6.0).Within(0.000001));
|
||||
Assert.That(chart.TempoMap.Nodes.Count, Is.EqualTo(2));
|
||||
Assert.That(chart.TempoMap.Nodes[1].Bpm, Is.EqualTo(180));
|
||||
Assert.That(chart.TempoMap.Nodes[1].StopDuration, Is.EqualTo(1.0 / 3.0).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Compiler_DerivesBpmRangeFromUsedTempoEventsOnly()
|
||||
{
|
||||
const string source = @"
|
||||
#BPM 150
|
||||
#BPM01 210
|
||||
#BPM02 999
|
||||
#SCROLL01 0.5
|
||||
#00103:5A
|
||||
#00208:01
|
||||
#002SC:01
|
||||
#00311:01
|
||||
";
|
||||
|
||||
var compiled = BmsTimelineCompiler.Compile(
|
||||
BmsParser.ParseText("tempo-range.bms", source));
|
||||
var playable = BmsPlayableChartConverter.Convert(compiled).Chart;
|
||||
|
||||
Assert.That(compiled.Metadata.InitialBpm, Is.EqualTo(150));
|
||||
Assert.That(compiled.Metadata.MinimumBpm, Is.EqualTo(90));
|
||||
Assert.That(compiled.Metadata.MaximumBpm, Is.EqualTo(210));
|
||||
Assert.That(playable.Metadata.InitialTempo, Is.EqualTo(150));
|
||||
Assert.That(playable.Metadata.MinimumTempo, Is.EqualTo(90));
|
||||
Assert.That(playable.Metadata.MaximumTempo, Is.EqualTo(210));
|
||||
Assert.That(
|
||||
compiled.Metadata.MaximumBpm,
|
||||
Is.Not.EqualTo(999),
|
||||
"An unused extended BPM definition must not enter the displayed range.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScrollMap_HoldsPositionForStopWhileSongTimeAdvances()
|
||||
{
|
||||
var tempoMap = new TempoMap(new[]
|
||||
{
|
||||
new TempoMapNode(0, 0, 120),
|
||||
new TempoMapNode(1, 0.5, 180, stopDuration: 1.0 / 3.0),
|
||||
});
|
||||
var scrollMap = new ScrollMap(tempoMap);
|
||||
|
||||
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1.0).Within(0.000001));
|
||||
Assert.That(scrollMap.PositionAtTime(0.7), Is.EqualTo(1.0).Within(0.000001));
|
||||
Assert.That(scrollMap.PositionAtTime(5.0 / 6.0), Is.EqualTo(1.0).Within(0.000001));
|
||||
Assert.That(scrollMap.PositionAtTime(1.0), Is.EqualTo(1.5).Within(0.000001));
|
||||
Assert.That(tempoMap.IsStopped(0.7), Is.True);
|
||||
Assert.That(tempoMap.IsStopped(1.0), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ScrollMap_TraversesZeroScrollBeforeFindingForwardVisibilityEnd()
|
||||
{
|
||||
var tempoMap = TempoMap.Constant(120);
|
||||
var scrollMap = new ScrollMap(tempoMap, new[]
|
||||
{
|
||||
new ScrollMapNode(1, 0),
|
||||
new ScrollMapNode(3, 1),
|
||||
});
|
||||
|
||||
var found = scrollMap.TryGetTimeAtForwardDistance(
|
||||
songTime: 0.25,
|
||||
distance: 1.0,
|
||||
out var visibilityEndTime);
|
||||
|
||||
Assert.That(found, Is.True);
|
||||
Assert.That(visibilityEndTime, Is.EqualTo(1.75).Within(0.000001));
|
||||
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1).Within(0.000001));
|
||||
Assert.That(scrollMap.PositionAtTime(1.5), Is.EqualTo(1).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Timeline_WarnsWhenScrollObjectIsUndefined()
|
||||
{
|
||||
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText(
|
||||
"undefined-scroll.bms",
|
||||
"#BPM 120\n#001SC:01\n#00111:01"));
|
||||
|
||||
Assert.That(chart.Warnings, Has.Some.Contains("Undefined SCROLL object 01"));
|
||||
Assert.That(chart.ScrollMap.HasVariableScroll, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TempoMap_NegativePrerollIsNotShiftedByAStopAtBeatZero()
|
||||
{
|
||||
var tempoMap = new TempoMap(new[]
|
||||
{
|
||||
new TempoMapNode(0, 0, 120, stopDuration: 0.5),
|
||||
});
|
||||
|
||||
Assert.That(tempoMap.TimeToBeat(-0.25), Is.EqualTo(-0.5).Within(0.000001));
|
||||
Assert.That(tempoMap.TimeToBeat(0.25), Is.Zero.Within(0.000001));
|
||||
Assert.That(tempoMap.TimeToBeat(0.75), Is.EqualTo(0.5).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNoteMusicalTicks_CrossTempoChangeWithoutDuplication()
|
||||
{
|
||||
var tempoMap = new TempoMap(new[]
|
||||
{
|
||||
new TempoMapNode(0, 0, 120),
|
||||
new TempoMapNode(1, 0.5, 180),
|
||||
});
|
||||
var note = new PlayableNote
|
||||
{
|
||||
Id = 1,
|
||||
Lane = 0,
|
||||
StartTime = 0,
|
||||
EndTime = 5.0 / 6.0,
|
||||
StartBeat = 0,
|
||||
EndBeat = 2,
|
||||
};
|
||||
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
|
||||
var engine = new RhythmJudgementEngine(new[] { note }, tempoMap: tempoMap);
|
||||
|
||||
engine.Press(0, 0);
|
||||
engine.Update(note.EndTime);
|
||||
|
||||
Assert.That(ticks, Has.Length.EqualTo(8));
|
||||
Assert.That(ticks[3], Is.EqualTo(0.5).Within(0.000001));
|
||||
Assert.That(ticks[4] - ticks[3], Is.EqualTo(1.0 / 12.0).Within(0.000001));
|
||||
Assert.That(engine.TotalScoringUnitCount, Is.EqualTo(9));
|
||||
Assert.That(engine.Combo, Is.EqualTo(9));
|
||||
Assert.That(engine.Score, Is.EqualTo(1_000_009));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SongClock_UsesDspNormallyAndFallsBackOnlyAfterARealStall()
|
||||
{
|
||||
var clock = new ResilientSongClock();
|
||||
clock.Reset(currentDspTime: 100.0, startDspTime: 100.0, currentRealtime: 0.0);
|
||||
|
||||
Assert.That(clock.Sample(100.1, 100.0, 0.1), Is.EqualTo(0.1).Within(0.000001));
|
||||
Assert.That(clock.IsFallbackActive, Is.False);
|
||||
Assert.That(clock.Sample(100.1, 100.0, 0.2), Is.EqualTo(0.1).Within(0.000001));
|
||||
Assert.That(clock.IsFallbackActive, Is.False);
|
||||
Assert.That(clock.Sample(100.1, 100.0, 0.4), Is.EqualTo(0.4).Within(0.000001));
|
||||
Assert.That(clock.IsFallbackActive, Is.True);
|
||||
Assert.That(clock.Sample(100.1, 100.0, 0.5), Is.EqualTo(0.5).Within(0.000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SongClock_MapsInputEventRelativeToTheCurrentInputClock()
|
||||
{
|
||||
var mapped = ResilientSongClock.ResolveInputEventSongTime(
|
||||
inputEventTime: 10.995,
|
||||
currentInputTime: 11.000,
|
||||
currentSongTime: 2.000);
|
||||
|
||||
Assert.That(mapped, Is.EqualTo(1.995).Within(0.000001));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 32a7895b9d504aa8bdbe439bebff65c7
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "ProjectQuint.Tests.Core",
|
||||
"references": ["ProjectQuint.Core"],
|
||||
"includePlatforms": ["Editor"],
|
||||
"optionalUnityReferences": ["TestAssemblies"],
|
||||
"autoReferenced": false,
|
||||
"noEngineReferences": true
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95d4580a877742e9ad20db382f8d33d3
|
||||
@@ -0,0 +1,497 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Bms;
|
||||
using ProjectQuint.Core.Charts;
|
||||
using ProjectQuint.Core.Course;
|
||||
using ProjectQuint.Core.Gauge;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
using ProjectQuint.Core.Timing;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class RhythmJudgementTests
|
||||
{
|
||||
[Test]
|
||||
public void LongNoteReleasePolicy_ExposesEightyThreeAndOneHundredMillisecondBoundaries()
|
||||
{
|
||||
var strictCandidate = new LongNoteReleasePolicy(0.083333);
|
||||
var djmaxCandidate = LongNoteReleasePolicy.DjmaxLike;
|
||||
|
||||
Assert.That(strictCandidate.Accepts(1.916667, 2.0), Is.True);
|
||||
Assert.That(strictCandidate.Accepts(1.916665, 2.0), Is.False);
|
||||
Assert.That(djmaxCandidate.Accepts(1.900000, 2.0), Is.True);
|
||||
Assert.That(djmaxCandidate.Accepts(1.899998, 2.0), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Judgement_KoolBoundaryIsInclusive()
|
||||
{
|
||||
var profile = JudgementProfile.ProjectQuint;
|
||||
|
||||
Assert.That(profile.Grade(0.042), Is.EqualTo(JudgementGrade.Kool));
|
||||
Assert.That(profile.Grade(0.042001), Is.EqualTo(JudgementGrade.Cool));
|
||||
Assert.That(profile.Grade(0.083), Is.EqualTo(JudgementGrade.Cool));
|
||||
Assert.That(profile.Grade(0.083001), Is.EqualTo(JudgementGrade.Good));
|
||||
Assert.That(profile.Grade(0.125), Is.EqualTo(JudgementGrade.Good));
|
||||
Assert.That(profile.Grade(0.125001), Is.EqualTo(JudgementGrade.Miss));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JudgementBreakdown_CountsEveryFinalGradeExactlyOnce()
|
||||
{
|
||||
var notes = Enumerable.Range(1, 4)
|
||||
.Select(index => new PlayableNote
|
||||
{
|
||||
Id = index,
|
||||
Lane = 0,
|
||||
StartTime = index,
|
||||
EndTime = index,
|
||||
})
|
||||
.ToArray();
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
engine.Press(0, 2.05);
|
||||
engine.Press(0, 3.10);
|
||||
engine.Update(4.2);
|
||||
|
||||
Assert.That(engine.KoolCount, Is.EqualTo(1));
|
||||
Assert.That(engine.CoolCount, Is.EqualTo(1));
|
||||
Assert.That(engine.GoodCount, Is.EqualTo(1));
|
||||
Assert.That(engine.MissCount, Is.EqualTo(1));
|
||||
Assert.That(
|
||||
engine.KoolCount + engine.CoolCount + engine.GoodCount + engine.MissCount,
|
||||
Is.EqualTo(engine.JudgedNoteCount));
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(47.5).Within(0.000001));
|
||||
Assert.That(engine.Score, Is.EqualTo(475001));
|
||||
Assert.That(engine.Combo, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_EarlyReleaseBreaksComboButRemainsVisibleUntilTail()
|
||||
{
|
||||
var note = new PlayableNote { Id = 7, Lane = 2, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
var head = engine.Press(2, 1.0);
|
||||
var release = engine.Release(2, 1.5);
|
||||
var retry = engine.Press(2, 1.6);
|
||||
|
||||
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
||||
Assert.That(release?.Grade, Is.EqualTo(JudgementGrade.Miss));
|
||||
Assert.That(release?.LongNoteBreak, Is.True);
|
||||
Assert.That(retry, Is.Null);
|
||||
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.BrokenHolding));
|
||||
Assert.That(engine.Combo, Is.Zero);
|
||||
Assert.That(engine.Score, Is.EqualTo(545461));
|
||||
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
|
||||
Assert.That(engine.ResultSequence, Is.EqualTo(7),
|
||||
"The head, five earned ticks, and long-note break must retrigger HUD feedback.");
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(600.0 / 11.0).Within(0.000001));
|
||||
Assert.That(engine.KoolCount, Is.EqualTo(6));
|
||||
Assert.That(engine.MissCount, Is.EqualTo(5));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(6));
|
||||
Assert.That(engine.IsFinished, Is.False);
|
||||
|
||||
engine.Update(2.0);
|
||||
|
||||
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.Failed));
|
||||
Assert.That(engine.IsFinished, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_MissedHeadRemainsUnfinishedUntilItsTail()
|
||||
{
|
||||
var note = new PlayableNote { Id = 8, Lane = 1, StartTime = 1.0, EndTime = 3.0, SoundId = "01" };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
engine.Update(1.2);
|
||||
|
||||
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.BrokenHolding),
|
||||
"A missed long-note head must enter the dead-until-tail state, not the terminal failed state.");
|
||||
Assert.That(engine.MissCount, Is.GreaterThan(0));
|
||||
Assert.That(engine.IsFinished, Is.False,
|
||||
"A head-missed long note must remain in the gameplay lifecycle until its tail.");
|
||||
|
||||
engine.Update(2.999);
|
||||
Assert.That(engine.IsFinished, Is.False);
|
||||
|
||||
engine.Update(3.0);
|
||||
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.Failed));
|
||||
Assert.That(engine.IsFinished, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Accuracy_UsesApprovedGradeWeightsAndOnlyJudgedNotes()
|
||||
{
|
||||
var notes = Enumerable.Range(0, 119)
|
||||
.Select(index => new PlayableNote
|
||||
{
|
||||
Id = index,
|
||||
Lane = 0,
|
||||
StartTime = index,
|
||||
EndTime = index,
|
||||
SoundId = "01",
|
||||
})
|
||||
.ToArray();
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
|
||||
for (var index = 0; index < 118; index++)
|
||||
engine.Press(0, index);
|
||||
engine.Press(0, 118.05);
|
||||
|
||||
Assert.That(engine.JudgedNoteCount, Is.EqualTo(119));
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(99.7478991597).Within(0.0000001));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Score_NormalizesAccuracyToOneMillionAndAddsPurePerfectBonus()
|
||||
{
|
||||
var notes = Enumerable.Range(0, 16)
|
||||
.Select(index => new PlayableNote
|
||||
{
|
||||
Id = index,
|
||||
Lane = 0,
|
||||
StartTime = index,
|
||||
EndTime = index,
|
||||
SoundId = "01",
|
||||
})
|
||||
.ToArray();
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
for (var index = 0; index < 15; index++)
|
||||
engine.Press(0, index);
|
||||
engine.Press(0, 15.05);
|
||||
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(98.125).Within(0.000001));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(15));
|
||||
Assert.That(engine.Score, Is.EqualTo(981265));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CourseScore_SumsPerStageScoresAndKeepsAccuracyAcrossStageTransition()
|
||||
{
|
||||
var first = new RhythmJudgementEngine(new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0 },
|
||||
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.0 },
|
||||
});
|
||||
first.Press(0, 1.0);
|
||||
first.Press(0, 2.05);
|
||||
var progress = new CourseProgress();
|
||||
progress.AddStage(first, cleared: true);
|
||||
|
||||
var second = new RhythmJudgementEngine(new[]
|
||||
{
|
||||
new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0 },
|
||||
new PlayableNote { Id = 4, Lane = 0, StartTime = 2.0 },
|
||||
});
|
||||
|
||||
Assert.That(progress.Score, Is.EqualTo(first.Score));
|
||||
Assert.That(progress.StageResults.Count, Is.EqualTo(1));
|
||||
Assert.That(
|
||||
progress.CombinedAccuracyPercent(second),
|
||||
Is.EqualTo(first.AccuracyPercent).Within(0.000001),
|
||||
"An unjudged next stage must not make the cumulative HUD rate jump to 100%.");
|
||||
Assert.That(progress.CombinedScore(second), Is.EqualTo(first.Score));
|
||||
|
||||
second.Press(0, 1.0);
|
||||
second.Press(0, 2.0);
|
||||
progress.AddStage(second, cleared: true);
|
||||
|
||||
Assert.That(progress.Score, Is.EqualTo(first.Score + second.Score));
|
||||
Assert.That(progress.StageResults.Count, Is.EqualTo(2));
|
||||
Assert.That(progress.StageResults[1].Score, Is.EqualTo(second.Score));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PurePerfectTheoreticalScore_AddsOnePointPerNeutralTimedNote()
|
||||
{
|
||||
const int noteCount = 3200;
|
||||
var notes = Enumerable.Range(0, noteCount)
|
||||
.Select(index => new PlayableNote
|
||||
{
|
||||
Id = index,
|
||||
Lane = index % 5,
|
||||
StartTime = index * 0.01,
|
||||
EndTime = index * 0.01,
|
||||
})
|
||||
.ToArray();
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
foreach (var note in notes)
|
||||
engine.Press(note.Lane, note.StartTime);
|
||||
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(noteCount));
|
||||
Assert.That(engine.Score, Is.EqualTo(1_003_200));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TimingStats_CountSuccessfulPressesButExcludeAutomaticMisses()
|
||||
{
|
||||
var notes = new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" },
|
||||
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0, SoundId = "01" },
|
||||
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0, SoundId = "01" },
|
||||
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0, SoundId = "01" },
|
||||
};
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
engine.Press(0, 0.98);
|
||||
engine.Press(1, 2.03);
|
||||
engine.Press(2, 3.0);
|
||||
engine.Update(4.2);
|
||||
|
||||
Assert.That(engine.FastCount, Is.Zero);
|
||||
Assert.That(engine.LateCount, Is.EqualTo(1));
|
||||
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(1));
|
||||
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.EqualTo(0.03).Within(0.000001));
|
||||
Assert.That(engine.AverageTimingOffsetMilliseconds, Is.EqualTo(10.0 / 3.0).Within(0.000001));
|
||||
Assert.That(engine.MissCount, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TimingFeedback_TreatsPlusOrMinusTwentyTwoMillisecondsAsNeutral()
|
||||
{
|
||||
var notes = new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
||||
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
|
||||
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0 },
|
||||
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0 },
|
||||
};
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
engine.Press(0, 0.978);
|
||||
engine.Press(1, 2.022);
|
||||
engine.Press(2, 2.977999);
|
||||
engine.Press(3, 4.022001);
|
||||
|
||||
Assert.That(engine.FastCount, Is.EqualTo(1));
|
||||
Assert.That(engine.LateCount, Is.EqualTo(1));
|
||||
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(2));
|
||||
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.LessThan(-0.022));
|
||||
Assert.That(engine.RecentTimingSamples[1].OffsetSeconds, Is.GreaterThan(0.022));
|
||||
Assert.That(engine.TimingHistogram.Sum(), Is.EqualTo(4));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(2));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void JudgementOffset_ShiftsHitTimingButKeepsPhysicalFeedbackTime()
|
||||
{
|
||||
var note = new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
var result = engine.Press(0, 0.98, 0.02);
|
||||
|
||||
Assert.That(result?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
||||
Assert.That(result?.OffsetSeconds, Is.Zero.Within(0.000001));
|
||||
Assert.That(engine.LastResultTime, Is.EqualTo(0.98).Within(0.000001));
|
||||
Assert.That(engine.FastCount, Is.Zero);
|
||||
Assert.That(engine.LateCount, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_HeldPastTailCompletesWithoutReleaseResult()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
engine.Update(2.1);
|
||||
var release = engine.Release(0, 2.2);
|
||||
|
||||
Assert.That(release, Is.Null);
|
||||
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
|
||||
Assert.That(engine.MissCount, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_ReleaseWithinDjmaxLikeOneHundredMillisecondsOfTailCompletes()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
var release = engine.Release(0, 2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds);
|
||||
|
||||
Assert.That(release, Is.Null);
|
||||
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
|
||||
Assert.That(engine.Combo, Is.EqualTo(11));
|
||||
Assert.That(engine.MissCount, Is.Zero);
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_ReleaseBeyondDjmaxLikeGraceStillFailsPermanently()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
var release = engine.Release(
|
||||
0,
|
||||
2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds - 0.000002);
|
||||
|
||||
Assert.That(release?.LongNoteBreak, Is.True);
|
||||
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.BrokenHolding));
|
||||
Assert.That(engine.Combo, Is.Zero);
|
||||
Assert.That(engine.MissCount, Is.EqualTo(2),
|
||||
"The 900 ms and tail ticks remain unearned just beyond the 100 ms grace.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_HoldTicksIncreaseComboEveryOneHundredMilliseconds()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.05 };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
var judgementResults = new List<JudgementResult>();
|
||||
engine.Judged += judgementResults.Add;
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
Assert.That(engine.Score, Is.EqualTo(90910));
|
||||
engine.Update(1.099);
|
||||
Assert.That(engine.Combo, Is.EqualTo(1));
|
||||
|
||||
engine.Update(1.1);
|
||||
Assert.That(engine.Combo, Is.EqualTo(2));
|
||||
Assert.That(engine.Score, Is.EqualTo(181820));
|
||||
|
||||
engine.Update(1.49);
|
||||
Assert.That(engine.Combo, Is.EqualTo(5));
|
||||
|
||||
engine.Update(2.05);
|
||||
Assert.That(engine.Combo, Is.EqualTo(11));
|
||||
Assert.That(engine.MaxCombo, Is.EqualTo(11));
|
||||
Assert.That(engine.Score, Is.EqualTo(1_000_011));
|
||||
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
|
||||
Assert.That(engine.KoolCount, Is.EqualTo(11));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(11));
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
|
||||
Assert.That(judgementResults, Has.Count.EqualTo(11));
|
||||
Assert.That(judgementResults[0].IsHoldTick, Is.False);
|
||||
Assert.That(judgementResults.Skip(1).All(result =>
|
||||
result.IsHoldTick && result.Grade == JudgementGrade.Kool), Is.True,
|
||||
"Every tick after a COOL-or-better head must pop a KOOL judgement.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_HoldTicksRaiseAccuracyAndScoreAfterAnImperfectHead()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
engine.Press(0, 1.05);
|
||||
var headScore = engine.Score;
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(70.0));
|
||||
|
||||
engine.Update(1.1);
|
||||
|
||||
Assert.That(engine.AccuracyPercent, Is.EqualTo(85.0));
|
||||
Assert.That(engine.Score, Is.GreaterThan(headScore));
|
||||
Assert.That(engine.PurePerfectCount, Is.EqualTo(1),
|
||||
"A COOL head promotes each successful long-note tick to KOOL Pure Perfect.");
|
||||
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Kool));
|
||||
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_GoodHeadKeepsEverySuccessfulTickGood()
|
||||
{
|
||||
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
|
||||
var engine = new RhythmJudgementEngine(new[] { note });
|
||||
|
||||
var head = engine.Press(0, 1.1);
|
||||
engine.Update(1.1);
|
||||
|
||||
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Good));
|
||||
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Good));
|
||||
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
|
||||
Assert.That(engine.GoodCount, Is.EqualTo(2));
|
||||
Assert.That(engine.KoolCount, Is.Zero);
|
||||
Assert.That(engine.PurePerfectCount, Is.Zero);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Judgement_SelectsNearestPendingNoteWithinTheLaneWindow()
|
||||
{
|
||||
var notes = new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
||||
new PlayableNote { Id = 2, Lane = 0, StartTime = 1.1, EndTime = 1.1 },
|
||||
};
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
var result = engine.Press(0, 1.08);
|
||||
|
||||
Assert.That(result?.NoteId, Is.EqualTo(2));
|
||||
Assert.That(result?.OffsetSeconds, Is.EqualTo(-0.02).Within(0.000001));
|
||||
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Pending));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Judgement_ResultDoesNotDependOnUpdateCadence()
|
||||
{
|
||||
var notes = new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
|
||||
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
|
||||
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 4.0 },
|
||||
};
|
||||
var frequentUpdates = new RhythmJudgementEngine(notes);
|
||||
var sparseUpdates = new RhythmJudgementEngine(notes);
|
||||
|
||||
for (var time = 0.0; time < 1.8; time += 0.01)
|
||||
frequentUpdates.Update(time);
|
||||
frequentUpdates.Press(1, 2.0);
|
||||
for (var time = 2.01; time < 2.9; time += 0.01)
|
||||
frequentUpdates.Update(time);
|
||||
frequentUpdates.Press(2, 3.0);
|
||||
frequentUpdates.Release(2, 3.5);
|
||||
for (var time = 3.51; time <= 4.5; time += 0.01)
|
||||
frequentUpdates.Update(time);
|
||||
|
||||
sparseUpdates.Update(1.8);
|
||||
sparseUpdates.Press(1, 2.0);
|
||||
sparseUpdates.Update(2.9);
|
||||
sparseUpdates.Press(2, 3.0);
|
||||
sparseUpdates.Release(2, 3.5);
|
||||
sparseUpdates.Update(4.5);
|
||||
|
||||
Assert.That(sparseUpdates.Score, Is.EqualTo(frequentUpdates.Score));
|
||||
Assert.That(sparseUpdates.MissCount, Is.EqualTo(frequentUpdates.MissCount));
|
||||
Assert.That(sparseUpdates.Combo, Is.EqualTo(frequentUpdates.Combo));
|
||||
Assert.That(sparseUpdates.AccuracyPercent, Is.EqualTo(frequentUpdates.AccuracyPercent));
|
||||
Assert.That(sparseUpdates.GetState(1), Is.EqualTo(frequentUpdates.GetState(1)));
|
||||
Assert.That(sparseUpdates.GetState(2), Is.EqualTo(frequentUpdates.GetState(2)));
|
||||
Assert.That(sparseUpdates.GetState(3), Is.EqualTo(frequentUpdates.GetState(3)));
|
||||
Assert.That(sparseUpdates.IsFinished, Is.True);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void LongNote_NextHeadCanBeJudgedAfterPreviousTailWithoutFrameUpdate()
|
||||
{
|
||||
var notes = new[]
|
||||
{
|
||||
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 2.0 },
|
||||
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.2, EndTime = 3.0 },
|
||||
};
|
||||
var engine = new RhythmJudgementEngine(notes);
|
||||
|
||||
engine.Press(0, 1.0);
|
||||
var secondHead = engine.Press(0, 2.2);
|
||||
|
||||
Assert.That(secondHead?.NoteId, Is.EqualTo(2));
|
||||
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Completed));
|
||||
Assert.That(engine.GetState(2), Is.EqualTo(RuntimeNoteState.Holding));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0d49a1c82734afea812ffafb41ce7b6
|
||||
@@ -105,26 +105,6 @@ namespace ProjectQuint.Tests
|
||||
Assert.That(app, Does.Not.Contain("DrawHome("));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void HomeActions_ReuseExistingTransitionQuitAndProfilePaths()
|
||||
{
|
||||
var app = Read("Unity/Bootstrap/ProjectQuintApp.cs");
|
||||
var actionStart = app.IndexOf(
|
||||
"private void ActivateHomeAction(HomeMenuAction action)",
|
||||
System.StringComparison.Ordinal);
|
||||
var actionEnd = app.IndexOf(
|
||||
"private void HandleHomePresenterAction",
|
||||
actionStart,
|
||||
System.StringComparison.Ordinal);
|
||||
var actionBody = app.Substring(actionStart, actionEnd - actionStart);
|
||||
|
||||
Assert.That(actionBody, Does.Contain("case HomeMenuAction.Play:"));
|
||||
Assert.That(actionBody, Does.Contain("_mode = AppMode.PackSelect;"));
|
||||
Assert.That(actionBody, Does.Contain("OpenOptions(AppMode.Home);"));
|
||||
Assert.That(actionBody, Does.Contain("Application.Quit();"));
|
||||
Assert.That(actionBody, Does.Contain("OpenProfileModal(0);"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MinimalHomeKeyboardAndMouseRoutesPlayAndEscapeQuit()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6613cb9e0883448468b1bfa7e788894e
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 551153b8a58bd4ddd8daf9dd5dcde978
|
||||
@@ -1,2 +0,0 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff984ac8f483b429aace8d868437ea96
|
||||
@@ -0,0 +1,41 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Features.Flow;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
internal sealed class HomeNavigationTests
|
||||
{
|
||||
[Test]
|
||||
public void PlayFromHome_TransitionsToPackSelection()
|
||||
{
|
||||
var flow = new AppFlowController();
|
||||
flow.TransitionTo(AppMode.Home);
|
||||
|
||||
Assert.That(flow.TryBeginPlayFromHome(), Is.True);
|
||||
Assert.That(flow.Mode, Is.EqualTo(AppMode.PackSelect));
|
||||
Assert.That(flow.Status, Is.EqualTo("SELECT A FOLDER OR ZIP TO BEGIN"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void PlayFromHome_PreservesTheSelectedPackAndExplainsRegisteredContent()
|
||||
{
|
||||
var flow = new AppFlowController();
|
||||
flow.TransitionTo(AppMode.Home);
|
||||
flow.TryBeginPlayFromHome(1, 3);
|
||||
Assert.That(flow.PackSelection, Is.EqualTo(1));
|
||||
Assert.That(flow.Status, Is.EqualTo("3 BMS PACKS REGISTERED"));
|
||||
}
|
||||
|
||||
[TestCase(AppMode.Loading)]
|
||||
[TestCase(AppMode.Gameplay)]
|
||||
[TestCase(AppMode.PackSelect)]
|
||||
public void HomePlayAction_DoesNotInterruptAnotherScreen(AppMode mode)
|
||||
{
|
||||
var flow = new AppFlowController();
|
||||
flow.TransitionTo(mode);
|
||||
|
||||
Assert.That(flow.TryBeginPlayFromHome(), Is.False);
|
||||
Assert.That(flow.Mode, Is.EqualTo(mode));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d5440faaa6cf473bb016ed8a641239b2
|
||||
@@ -7,6 +7,7 @@ using ProjectQuint.Core.Progression;
|
||||
using ProjectQuint.Library;
|
||||
using ProjectQuint.Persistence;
|
||||
using ProjectQuint.Bootstrap;
|
||||
using ProjectQuint.Features.Profile;
|
||||
using UnityEngine;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
@@ -230,7 +231,7 @@ namespace ProjectQuint.Tests
|
||||
})
|
||||
.ToArray();
|
||||
|
||||
var contexts = ProjectQuintApp.BuildProfileRatingContexts(
|
||||
var contexts = ProfileRatingContextBuilder.Build(
|
||||
new[] { recorded },
|
||||
tables,
|
||||
Array.Empty<SongLibraryEntry>(),
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using System;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Progression;
|
||||
using ProjectQuint.Features.Profile;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
internal sealed class ProfileSessionControllerTests
|
||||
{
|
||||
[Test]
|
||||
public void RatingSelection_WrapsAndRefreshClampsToAvailableContexts()
|
||||
{
|
||||
var session = new ProfileSessionController();
|
||||
var contexts = new[]
|
||||
{
|
||||
new RatingSummary("first", "FIRST", 4, false, 1, 1),
|
||||
new RatingSummary("second", "SECOND", 7, true, 2, 1),
|
||||
};
|
||||
session.Refresh(default, null, null, contexts, default);
|
||||
session.ShiftRatingContext(-1);
|
||||
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("second"));
|
||||
session.Refresh(default, null, null, Array.Empty<RatingSummary>(), contexts[0]);
|
||||
Assert.That(session.RatingContextIndex, Is.Zero);
|
||||
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("first"));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CollectionSelection_ClampsAndSurvivesAnEmptyRefresh()
|
||||
{
|
||||
var session = new ProfileSessionController();
|
||||
var groups = new[] { default(CollectionGroupSummary), default(CollectionGroupSummary) };
|
||||
session.Refresh(default, new ProfileCollectionSnapshot(default, groups, null, null),
|
||||
null, null, default);
|
||||
session.SelectCollection(0, 100);
|
||||
Assert.That(session.CollectionSelections[0], Is.EqualTo(1));
|
||||
session.ShiftCollectionSelection(1);
|
||||
Assert.That(session.CollectionSelections[0], Is.Zero);
|
||||
session.ShiftCollectionColumn(-1);
|
||||
Assert.That(session.CollectionColumn, Is.EqualTo(2));
|
||||
session.Refresh(default, null, null, null, default);
|
||||
session.SelectCollection(-1, -1);
|
||||
Assert.That(session.CollectionColumn, Is.Zero);
|
||||
Assert.That(session.CollectionSelections, Is.EqualTo(new[] { 0, 0, 0 }));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Modal_ClampsTabsAndCloseIsIdempotent()
|
||||
{
|
||||
var session = new ProfileSessionController();
|
||||
session.Open(100);
|
||||
Assert.That(session.IsOpen, Is.True);
|
||||
Assert.That(session.Tab, Is.EqualTo(3));
|
||||
session.ShiftTab(1);
|
||||
Assert.That(session.Tab, Is.Zero);
|
||||
session.Close();
|
||||
session.Close();
|
||||
Assert.That(session.IsOpen, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void EmptyBadgeCollection_DoesNotOfferAPersistableSelection()
|
||||
{
|
||||
var session = new ProfileSessionController();
|
||||
session.SelectBadge(100);
|
||||
session.ShiftBadge(-1);
|
||||
Assert.That(session.BadgeSelection, Is.Zero);
|
||||
Assert.That(session.SelectedBadgeCourseId, Is.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cc915c84e7104cebbd016972b39e1362
|
||||
@@ -24,7 +24,7 @@ namespace ProjectQuint.Tests
|
||||
Is.True,
|
||||
error);
|
||||
Assert.That(manifest.buildNumber, Is.GreaterThan(ProjectQuintReleaseVersion.BuildNumber));
|
||||
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.True);
|
||||
Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.True);
|
||||
}
|
||||
|
||||
[TestCase("\"schemaVersion\":1", "\"schemaVersion\":2")]
|
||||
@@ -57,13 +57,13 @@ namespace ProjectQuint.Tests
|
||||
out _);
|
||||
|
||||
Assert.That(
|
||||
ProjectQuintUpdateService.ShouldOffer(
|
||||
UpdatePolicy.ShouldOffer(
|
||||
manifest,
|
||||
manifest.version,
|
||||
manual: false),
|
||||
Is.False);
|
||||
Assert.That(
|
||||
ProjectQuintUpdateService.ShouldOffer(
|
||||
UpdatePolicy.ShouldOffer(
|
||||
manifest,
|
||||
manifest.version,
|
||||
manual: true),
|
||||
@@ -76,17 +76,17 @@ namespace ProjectQuint.Tests
|
||||
var now = new DateTime(2026, 7, 29, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
Assert.That(
|
||||
ProjectQuintUpdateService.ShouldCheckAutomatically(
|
||||
UpdatePolicy.ShouldCheckAutomatically(
|
||||
now.AddHours(-23).Ticks,
|
||||
now.Ticks),
|
||||
Is.False);
|
||||
Assert.That(
|
||||
ProjectQuintUpdateService.ShouldCheckAutomatically(
|
||||
UpdatePolicy.ShouldCheckAutomatically(
|
||||
now.AddHours(-24).Ticks,
|
||||
now.Ticks),
|
||||
Is.True);
|
||||
Assert.That(
|
||||
ProjectQuintUpdateService.ShouldCheckAutomatically(0, now.Ticks),
|
||||
UpdatePolicy.ShouldCheckAutomatically(0, now.Ticks),
|
||||
Is.True);
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace ProjectQuint.Tests
|
||||
out var error),
|
||||
Is.True,
|
||||
error);
|
||||
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.False);
|
||||
Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Core.Release;
|
||||
using ProjectQuint.Features.Flow;
|
||||
using ProjectQuint.Features.Updates;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
internal sealed class UpdateSessionControllerTests
|
||||
{
|
||||
private static ProjectQuintUpdateManifest NewerBuild() => new ProjectQuintUpdateManifest
|
||||
{
|
||||
available = true,
|
||||
version = "0.1.0-alpha.99",
|
||||
buildNumber = ProjectQuintReleaseVersion.BuildNumber + 1,
|
||||
};
|
||||
|
||||
[Test]
|
||||
public void DuplicateCheck_IsRejectedUntilTheCurrentCheckCompletes()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
Assert.That(session.TryBeginCheck(true, out var id), Is.True);
|
||||
Assert.That(session.TryBeginCheck(true, out _), Is.False);
|
||||
Assert.That(session.CompleteCheck(id,
|
||||
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.True);
|
||||
Assert.That(session.IsChecking, Is.False);
|
||||
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.EqualTo(100));
|
||||
Assert.That(session.CompleteCheck(id,
|
||||
new ProjectQuintUpdateCheckResult(false, null, "late failure"), "", 200), Is.False);
|
||||
Assert.That(session.Error, Is.Empty);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void CancelledCheck_CannotOverwriteAReplacementCheck()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
session.TryBeginCheck(false, out var oldId);
|
||||
session.Cancel();
|
||||
session.TryBeginCheck(true, out var newId);
|
||||
|
||||
Assert.That(session.CompleteCheck(oldId,
|
||||
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.False);
|
||||
session.AbortCheck(oldId);
|
||||
Assert.That(session.IsChecking, Is.True);
|
||||
Assert.That(session.CompleteCheck(newId,
|
||||
new ProjectQuintUpdateCheckResult(false, null, "network unavailable"), "", 200), Is.True);
|
||||
Assert.That(session.Error, Is.EqualTo("network unavailable"));
|
||||
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
|
||||
}
|
||||
|
||||
[TestCase(AppMode.Gameplay, false, false)]
|
||||
[TestCase(AppMode.Loading, false, false)]
|
||||
[TestCase(AppMode.Home, true, false)]
|
||||
[TestCase(AppMode.Options, true, false)]
|
||||
[TestCase(AppMode.Home, false, true)]
|
||||
[TestCase(AppMode.Options, false, true)]
|
||||
public void Offer_WaitsForAnUnblockedHomeOrOptionsScreen(AppMode mode, bool blocked, bool expected)
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
session.TryBeginCheck(false, out var id);
|
||||
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100);
|
||||
|
||||
Assert.That(session.TryPresent(mode, blocked), Is.EqualTo(expected));
|
||||
Assert.That(session.IsModalOpen, Is.EqualTo(expected));
|
||||
Assert.That(session.TryPresent(mode, blocked), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SkippedVersion_IsSilentAutomaticallyButCanBeRequestedManually()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
var manifest = NewerBuild();
|
||||
session.TryBeginCheck(false, out var id);
|
||||
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 100);
|
||||
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
|
||||
|
||||
session.TryBeginCheck(true, out id);
|
||||
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 200);
|
||||
Assert.That(session.TryPresent(AppMode.Home, false), Is.True);
|
||||
session.MoveSelection(-1);
|
||||
Assert.That(session.Selection, Is.EqualTo(2));
|
||||
session.Dismiss();
|
||||
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutomaticCheck_StartsOnceAtHomeAndNeverInTheEditor()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
Assert.That(session.TryBeginAutomaticCheck(AppMode.Gameplay, false, 0, 100, out _), Is.False);
|
||||
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 100, out _), Is.True);
|
||||
session.Cancel();
|
||||
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 200, out _), Is.False);
|
||||
var editor = new UpdateSessionController();
|
||||
Assert.That(editor.TryBeginAutomaticCheck(AppMode.Home, true, 0, 100, out _), Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AutomaticCheck_RespectsTheLastSuccessfulCheckTime()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
var now = System.DateTime.UtcNow;
|
||||
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false,
|
||||
now.AddHours(-23).Ticks, now.Ticks, out _), Is.False);
|
||||
Assert.That(session.IsChecking, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void SuccessWithoutAManifest_IsAnObservableFailure()
|
||||
{
|
||||
var session = new UpdateSessionController();
|
||||
session.TryBeginCheck(true, out var id);
|
||||
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, null, ""), "", 100);
|
||||
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
|
||||
Assert.That(session.Error, Is.EqualTo("UPDATE MANIFEST IS MISSING"));
|
||||
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cd82750b60d84a0f980a0721221c1f9d
|
||||
@@ -5,6 +5,26 @@
|
||||
|
||||
[프로젝트 사이트](https://projectquint.duckdns.org/)에서 사용 안내와 배포 정보를 확인할 수 있습니다.
|
||||
|
||||
## 독립 테스트 실행
|
||||
|
||||
[.NET 10 SDK](https://learn.microsoft.com/en-us/dotnet/core/install/)가 있으면 유니티를 설치하지 않고
|
||||
판정·타이밍·파서와 주요 화면 상태 테스트를 실행할 수 있습니다.
|
||||
macOS와 Linux에서는 저장소 루트에서 다음 명령을 실행합니다.
|
||||
|
||||
```bash
|
||||
Tools/agentctl test
|
||||
Tools/agentctl replay --notes 1000 --seed 42 --fps 30
|
||||
Tools/agentctl replay --notes 1000 --seed 42 --fps 240
|
||||
```
|
||||
|
||||
재생 검증에는 생성한 노트와 입력 이벤트만 사용하며 음악 파일이나 외부 채보를 읽지 않습니다.
|
||||
두 재생 결과의 `result.judgement`는 같아야 합니다. `--fps`는 가상 프레임 갱신 간격이며
|
||||
실제 게임의 초당 프레임 수를 측정하는 옵션은 아닙니다.
|
||||
|
||||
`Verification/`의 프로젝트는 실제 게임 소스와 테스트 파일을 직접 컴파일합니다.
|
||||
검증용으로 별도의 판정 엔진을 구현하거나 유니티 API를 모방하지 않습니다.
|
||||
첫 실행에는 테스트 패키지를 복원할 인터넷 연결이 필요합니다.
|
||||
|
||||
## 주요 기능과 구성
|
||||
|
||||
- **판정과 타이밍**: 렌더링 프레임과 분리한 판정 엔진, BPM·STOP을 처리하는 시간 변환,
|
||||
@@ -13,6 +33,42 @@
|
||||
- **라이브러리와 저장**: 팩별 곡 목록, 난이도표 연결, 검색과 정렬, 플레이 결과·프로필 저장을 관리합니다.
|
||||
- **화면 구성**: UI Toolkit 화면과 프레젠터를 사용해 화면 표시와 세션 상태를 나눴습니다.
|
||||
|
||||
## 설계
|
||||
|
||||
### 프레임 갱신 빈도와 판정 결과 분리
|
||||
|
||||
입력을 처리한 프레임의 시각으로 판정하면 프레임 지연이 점수에 영향을 줄 수 있습니다.
|
||||
`RhythmJudgementEngine`은 전달받은 곡 시각으로 판정하고,
|
||||
`TempoMap`과 `ScrollMap`은 음악의 시간 변환과 화면 위치 계산을 나눠 담당합니다.
|
||||
[판정 테스트](Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs)와
|
||||
[합성 입력 재생](Verification/ProjectQuint.Tests/CoreReplayTests.cs)에서 서로 다른 갱신 간격의 결과를 비교합니다.
|
||||
|
||||
### 비동기 작업의 수명과 화면 상태 분리
|
||||
|
||||
곡 교체는 준비한 리소스를 한 묶음으로 넘기고, 취소된 후보는 해제합니다.
|
||||
업데이트 알림은 `UpdateSessionController`가 요청 세대와 화면 상태를 관리합니다.
|
||||
취소된 요청의 늦은 응답은 무시하며, 게임 중에는 알림을 표시하지 않습니다.
|
||||
[업데이트 상태 테스트](Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs)는
|
||||
중복 요청·취소 후 재요청·표시 가능한 화면·같은 응답의 중복 전달을 검증합니다.
|
||||
|
||||
### 입력 장치와 프로필 화면 상태 분리
|
||||
|
||||
`ProfileSessionController`가 탭, 난이도표별 레이팅, 수집 기록과 배지의 선택 상태를 소유합니다.
|
||||
키보드와 UI 이벤트는 같은 상태 변경 메서드를 호출합니다.
|
||||
표시할 데이터가 줄거나 비어도 선택 인덱스가 유효하도록
|
||||
[프로필 상태 테스트](Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs)에서 확인합니다.
|
||||
|
||||
## 측정 결과
|
||||
|
||||
합성 노트 10,000개와 입력 이벤트 18,822개를 사용하는 판정 코어 측정에서,
|
||||
전체 입력 재생 시간의 중앙값은 **4.831 ms**였습니다.
|
||||
엔진 생성은 별도로 측정했으며 중앙값은 **0.995 ms**였습니다.
|
||||
이 입력 재생 구간에서 현재 스레드의 관리 메모리 추가 할당은 20회 모두 0바이트였습니다.
|
||||
|
||||
이 값은 macOS ARM64·.NET 10 환경에서 측정한 코어의 기준값입니다.
|
||||
유니티 플레이어의 프레임 시간, 실제 입력·오디오 지연, FMOD 메모리 사용량을 뜻하지 않습니다.
|
||||
[측정 조건·원자료·재현 방법](docs/verification.md)을 함께 확인할 수 있습니다.
|
||||
|
||||
## 주요 경로
|
||||
|
||||
| 경로 | 역할 |
|
||||
@@ -22,7 +78,9 @@
|
||||
| `Assets/ProjectQuint/Runtime/Unity/Audio` | 오디오 백엔드와 재생·캐시 |
|
||||
| `Assets/ProjectQuint/Runtime/Unity/Persistence` | 플레이 결과와 프로필 저장 |
|
||||
| `Assets/ProjectQuint/Runtime/Unity/UI` | 화면 프레젠터와 UI 리소스 |
|
||||
| `Assets/ProjectQuint/Tests/EditMode` | 유니티 편집 모드 테스트 |
|
||||
| `Assets/ProjectQuint/Tests/Core` | 유니티와 .NET에서 공유하는 코어 테스트 |
|
||||
| `Assets/ProjectQuint/Tests/EditMode` | 앱 상태·유니티 통합 테스트 |
|
||||
| `Verification` | 독립 테스트 실행과 합성 입력·성능 측정 도구 |
|
||||
| `Tools` | 빌드·배포 도구와 테스트 |
|
||||
| `Website/projectquint-site` | 프로젝트 소개 사이트 |
|
||||
|
||||
@@ -37,6 +95,9 @@ BMS 음악과 채보는 포함되어 있지 않으므로, 플레이할 콘텐츠
|
||||
|
||||
## 검증
|
||||
|
||||
독립 검증의 실행 방법과 검증 범위는 [검증 안내](docs/verification.md)에 정리되어 있습니다.
|
||||
독립 테스트 통과만으로 유니티 화면·오디오·네이티브 연동이 검증되었다고 보지 않습니다.
|
||||
|
||||
유니티 Test Runner에서 `EditMode` 테스트를 실행합니다. 명령줄 실행 시에는
|
||||
`-runTests -testPlatform EditMode -testResults <결과 파일> -logFile <로그 파일>`을 사용하고,
|
||||
테스트 시작 전에 종료되지 않도록 `-quit`는 함께 전달하지 않습니다.
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
project_root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
dotnet_command=${PROJECTQUINT_DOTNET:-dotnet}
|
||||
if ! command -v "$dotnet_command" >/dev/null 2>&1; then
|
||||
echo "Install the .NET 10 SDK or set PROJECTQUINT_DOTNET to its executable." >&2
|
||||
exit 127
|
||||
fi
|
||||
export DOTNET_CLI_TELEMETRY_OPTOUT=1
|
||||
export DOTNET_NOLOGO=1
|
||||
cd "$project_root/Verification"
|
||||
|
||||
if [ "${1:-}" = test ]; then
|
||||
shift
|
||||
exec "$dotnet_command" test ProjectQuint.Tests/ProjectQuint.Tests.csproj \
|
||||
--configuration Release -p:RestoreLockedMode=true "$@"
|
||||
fi
|
||||
|
||||
# Keep build diagnostics on stderr so replay/benchmark stdout is exactly one JSON document.
|
||||
"$dotnet_command" build ProjectQuint.Agent/ProjectQuint.Agent.csproj \
|
||||
--configuration Release --verbosity quiet >&2
|
||||
exec "$dotnet_command" ProjectQuint.Agent/bin/Release/net10.0/ProjectQuint.Agent.dll "$@"
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using ProjectQuint.Probes;
|
||||
|
||||
var json = new JsonSerializerOptions { WriteIndented = true, PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
try
|
||||
{
|
||||
var action = args.Length == 0 ? "list" : args[0];
|
||||
var options = new Dictionary<string, int>(StringComparer.Ordinal);
|
||||
string? output = null;
|
||||
for (var index = 1; index < args.Length; index += 2)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
throw new ArgumentException("Every option requires a value.");
|
||||
if (args[index] == "--output" && output == null)
|
||||
output = args[index + 1];
|
||||
else if (args[index] is "--notes" or "--seed" or "--fps" or "--iterations" &&
|
||||
int.TryParse(args[index + 1], out var value) && options.TryAdd(args[index], value))
|
||||
continue;
|
||||
else
|
||||
throw new ArgumentException("Unknown, duplicated or invalid option.");
|
||||
}
|
||||
var notes = options.GetValueOrDefault("--notes", 10_000);
|
||||
var seed = options.GetValueOrDefault("--seed", 42);
|
||||
var fps = options.GetValueOrDefault("--fps", 240);
|
||||
var iterations = options.GetValueOrDefault("--iterations", 20);
|
||||
if (seed < 0)
|
||||
throw new ArgumentException("Seed must be nonnegative.");
|
||||
object report;
|
||||
switch (action)
|
||||
{
|
||||
case "list":
|
||||
if (options.Count != 0 || output != null)
|
||||
throw new ArgumentException("The list command accepts no options.");
|
||||
report = new
|
||||
{
|
||||
schemaVersion = 1,
|
||||
component = "headless-core",
|
||||
commands = new[] { "list", "replay", "benchmark", "test (wrapper)" },
|
||||
input = "seeded synthetic four-lane notes; no music or external library",
|
||||
options = new[] { "--notes", "--seed", "--fps", "--iterations (benchmark)", "--output" },
|
||||
};
|
||||
break;
|
||||
case "replay":
|
||||
if (options.ContainsKey("--iterations"))
|
||||
throw new ArgumentException("Iterations applies only to benchmarks.");
|
||||
var workload = SyntheticWorkload.Create(notes, (uint)seed);
|
||||
report = new
|
||||
{
|
||||
schemaVersion = 1, complete = true, notes, seed, frameRate = fps,
|
||||
eventCount = workload.Events.Length,
|
||||
result = ReplayRunner.Run(workload, fps),
|
||||
};
|
||||
break;
|
||||
case "benchmark":
|
||||
report = CoreBenchmark.Run(notes, (uint)seed, fps, iterations, checkpoint =>
|
||||
{
|
||||
Console.Error.WriteLine($"benchmark {checkpoint.Samples.Length}/{iterations}");
|
||||
if (output != null)
|
||||
Save(output, checkpoint);
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new ArgumentException("Unknown command. Use list, replay, benchmark or test.");
|
||||
}
|
||||
if (output != null)
|
||||
Save(output, report);
|
||||
Console.WriteLine(JsonSerializer.Serialize(report, json));
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
// Paths in I/O exceptions can identify the local machine; keep structured failures generic.
|
||||
Console.Error.WriteLine(JsonSerializer.Serialize(new
|
||||
{
|
||||
error = error.GetType().Name,
|
||||
detail = error is ArgumentException ? error.Message : "The probe failed; no complete result was produced.",
|
||||
}));
|
||||
return 1;
|
||||
}
|
||||
|
||||
void Save(string path, object report)
|
||||
{
|
||||
var destination = Path.GetFullPath(path);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
|
||||
var temporary = destination + ".tmp";
|
||||
File.WriteAllText(temporary, JsonSerializer.Serialize(report, json) + "\n");
|
||||
File.Move(temporary, destination, overwrite: true);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Compile the shipping sources, never a separate implementation of the rules. -->
|
||||
<Compile Include="../../Assets/ProjectQuint/Runtime/Core/**/*.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,42 @@
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ProjectQuint.Probes;
|
||||
|
||||
public sealed record CoreBenchmarkReport(int SchemaVersion, bool Complete, DateTimeOffset MeasuredAtUtc,
|
||||
string Runtime, string OperatingSystem, string Architecture, int ProcessorCount,
|
||||
int Notes, int Events, uint Seed, int FrameRate, int WarmupIterations, int RequestedIterations,
|
||||
ReplayObservation[] Samples);
|
||||
|
||||
public static class CoreBenchmark
|
||||
{
|
||||
public static CoreBenchmarkReport Run(int notes, uint seed, int frameRate, int iterations,
|
||||
Action<CoreBenchmarkReport> checkpoint)
|
||||
{
|
||||
if (iterations < 1 || iterations > 100)
|
||||
throw new ArgumentOutOfRangeException(nameof(iterations), "Use 1 to 100 iterations.");
|
||||
var workload = SyntheticWorkload.Create(notes, seed);
|
||||
const int warmups = 3;
|
||||
JudgementSnapshot expected = default;
|
||||
for (var index = 0; index < warmups; index++)
|
||||
expected = ReplayRunner.Run(workload, frameRate).Judgement;
|
||||
var samples = new List<ReplayObservation>(iterations);
|
||||
var measuredAt = DateTimeOffset.UtcNow;
|
||||
for (var index = 0; index < iterations; index++)
|
||||
{
|
||||
var sample = ReplayRunner.Run(workload, frameRate);
|
||||
if (sample.Judgement != expected || !sample.Judgement.Finished)
|
||||
throw new InvalidOperationException("The benchmark replay diverged from its warmup result.");
|
||||
samples.Add(sample);
|
||||
checkpoint(Report(false));
|
||||
}
|
||||
return Report(true);
|
||||
|
||||
CoreBenchmarkReport Report(bool complete) => new(1, complete, measuredAt,
|
||||
RuntimeInformation.FrameworkDescription,
|
||||
System.OperatingSystem.IsMacOS() ? "macOS" :
|
||||
System.OperatingSystem.IsLinux() ? "Linux" :
|
||||
System.OperatingSystem.IsWindows() ? "Windows" : "Other",
|
||||
RuntimeInformation.ProcessArchitecture.ToString(), Environment.ProcessorCount,
|
||||
notes, workload.Events.Length, seed, frameRate, warmups, iterations, samples.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Diagnostics;
|
||||
using ProjectQuint.Core.Judgement;
|
||||
|
||||
namespace ProjectQuint.Probes;
|
||||
|
||||
public readonly record struct JudgementSnapshot(int Score, int Combo, int MaxCombo,
|
||||
int MissCount, int KoolCount, int CoolCount, int GoodCount, int JudgedUnits, bool Finished);
|
||||
|
||||
public sealed record ReplayObservation(JudgementSnapshot Judgement, double SetupMilliseconds,
|
||||
double ReplayMilliseconds, long SetupAllocatedBytes, long ReplayAllocatedBytes);
|
||||
|
||||
public static class ReplayRunner
|
||||
{
|
||||
public static ReplayObservation Run(SyntheticWorkload workload, int frameRate)
|
||||
{
|
||||
if (frameRate < 1 || frameRate > 10_000)
|
||||
throw new ArgumentOutOfRangeException(nameof(frameRate), "Use a frame rate from 1 to 10000.");
|
||||
var allocationStart = GC.GetAllocatedBytesForCurrentThread();
|
||||
var setupStart = Stopwatch.GetTimestamp();
|
||||
var engine = new RhythmJudgementEngine(workload.Notes, laneCount: 4);
|
||||
var setupEnd = Stopwatch.GetTimestamp();
|
||||
var replayAllocationStart = GC.GetAllocatedBytesForCurrentThread();
|
||||
|
||||
long frame = 0;
|
||||
foreach (var input in workload.Events)
|
||||
{
|
||||
// Merge two timestamped streams; no sleeps or real-time waiting affect this trace.
|
||||
while ((double)frame / frameRate <= input.Time)
|
||||
engine.Update((double)frame++ / frameRate);
|
||||
if (input.Pressed)
|
||||
engine.Press(input.Lane, input.Time);
|
||||
else
|
||||
engine.Release(input.Lane, input.Time);
|
||||
}
|
||||
engine.Update(workload.Notes[^1].EndTime + 1);
|
||||
var replayEnd = Stopwatch.GetTimestamp();
|
||||
var allocationEnd = GC.GetAllocatedBytesForCurrentThread();
|
||||
return new ReplayObservation(new JudgementSnapshot(engine.Score, engine.Combo, engine.MaxCombo,
|
||||
engine.MissCount, engine.KoolCount, engine.CoolCount, engine.GoodCount,
|
||||
engine.JudgedNoteCount, engine.IsFinished),
|
||||
Milliseconds(setupStart, setupEnd), Milliseconds(setupEnd, replayEnd),
|
||||
replayAllocationStart - allocationStart, allocationEnd - replayAllocationStart);
|
||||
}
|
||||
|
||||
private static double Milliseconds(long start, long end) =>
|
||||
(end - start) * 1000.0 / Stopwatch.Frequency;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using ProjectQuint.Core.Charts;
|
||||
|
||||
namespace ProjectQuint.Probes;
|
||||
|
||||
public readonly record struct ReplayInput(double Time, int Lane, bool Pressed);
|
||||
|
||||
public sealed record SyntheticWorkload(PlayableNote[] Notes, ReplayInput[] Events, uint Seed)
|
||||
{
|
||||
public static SyntheticWorkload Create(int noteCount, uint seed)
|
||||
{
|
||||
if (noteCount < 1 || noteCount > 100_000)
|
||||
throw new ArgumentOutOfRangeException(nameof(noteCount), "Use 1 to 100000 notes.");
|
||||
var notes = new PlayableNote[noteCount];
|
||||
var events = new List<ReplayInput>(noteCount * 2);
|
||||
var state = seed;
|
||||
for (var index = 0; index < noteCount; index++)
|
||||
{
|
||||
// Explicit integer PRNG makes the workload independent of System.Random versions.
|
||||
state = unchecked(state * 1664525u + 1013904223u);
|
||||
var time = 1.0 + index * 0.125;
|
||||
var longNote = index % 11 == 0;
|
||||
var note = new PlayableNote
|
||||
{
|
||||
Id = index + 1,
|
||||
Lane = index % 4,
|
||||
StartTime = time,
|
||||
EndTime = time + (longNote ? 0.375 : 0),
|
||||
};
|
||||
notes[index] = note;
|
||||
// Deliberate misses and early releases exercise failure as well as perfect play.
|
||||
if (index % 17 == 0)
|
||||
continue;
|
||||
var pressTime = time + ((int)(state % 7) - 3) * 0.01;
|
||||
events.Add(new ReplayInput(pressTime, note.Lane, true));
|
||||
events.Add(new ReplayInput(longNote
|
||||
? note.EndTime - (index % 23 == 0 ? 0.2 : 0)
|
||||
: pressTime + 0.02, note.Lane, false));
|
||||
}
|
||||
return new SyntheticWorkload(notes, events.OrderBy(e => e.Time)
|
||||
.ThenBy(e => e.Pressed).ThenBy(e => e.Lane).ToArray(), seed);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Probes;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class CoreBenchmarkTests
|
||||
{
|
||||
[Test]
|
||||
public void Benchmark_CheckpointsAreIncompleteUntilEverySampleFinishes()
|
||||
{
|
||||
var checkpoints = 0;
|
||||
var report = CoreBenchmark.Run(100, 42, 60, 3, checkpoint =>
|
||||
{
|
||||
++checkpoints;
|
||||
Assert.That(checkpoint.Complete, Is.False);
|
||||
Assert.That(checkpoint.Samples.Length, Is.EqualTo(checkpoints));
|
||||
Assert.That(checkpoint.Samples[0].Judgement.Finished, Is.True);
|
||||
});
|
||||
Assert.That(report.Complete, Is.True);
|
||||
Assert.That(checkpoints, Is.EqualTo(3));
|
||||
Assert.That(report.Samples.Length, Is.EqualTo(3));
|
||||
Assert.That(report.WarmupIterations, Is.EqualTo(3));
|
||||
}
|
||||
|
||||
[TestCase(0)]
|
||||
[TestCase(101)]
|
||||
public void Benchmark_RejectsInvalidIterationCount(int iterations) =>
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
|
||||
CoreBenchmark.Run(100, 42, 60, iterations, _ => { }));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Linq;
|
||||
using NUnit.Framework;
|
||||
using ProjectQuint.Probes;
|
||||
|
||||
namespace ProjectQuint.Tests
|
||||
{
|
||||
public sealed class CoreReplayTests
|
||||
{
|
||||
[Test]
|
||||
public void SyntheticWorkload_IsReproducibleWithoutMedia()
|
||||
{
|
||||
var first = SyntheticWorkload.Create(500, 42);
|
||||
var second = SyntheticWorkload.Create(500, 42);
|
||||
Assert.That(first.Events, Is.EqualTo(second.Events));
|
||||
Assert.That(first.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime)),
|
||||
Is.EqualTo(second.Notes.Select(n => (n.Id, n.Lane, n.StartTime, n.EndTime))));
|
||||
}
|
||||
|
||||
[TestCase(30)]
|
||||
[TestCase(60)]
|
||||
[TestCase(144)]
|
||||
[TestCase(240)]
|
||||
public void Replay_KeepsTheSameJudgementsAcrossFrameSchedules(int frameRate)
|
||||
{
|
||||
var workload = SyntheticWorkload.Create(1000, 42);
|
||||
var expected = ReplayRunner.Run(workload, 1000).Judgement;
|
||||
var actual = ReplayRunner.Run(workload, frameRate).Judgement;
|
||||
Assert.That(actual, Is.EqualTo(expected));
|
||||
Assert.That(actual.Finished, Is.True);
|
||||
Assert.That(actual.MissCount, Is.GreaterThan(0));
|
||||
Assert.That(actual.JudgedUnits, Is.GreaterThan(workload.Notes.Length));
|
||||
}
|
||||
|
||||
[TestCase(0)]
|
||||
[TestCase(-1)]
|
||||
public void InvalidWorkloadSize_IsRejected(int notes) =>
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(() => SyntheticWorkload.Create(notes, 42));
|
||||
|
||||
[Test]
|
||||
public void InvalidFrameRate_IsRejected() =>
|
||||
Assert.Throws<System.ArgumentOutOfRangeException>(() =>
|
||||
ReplayRunner.Run(SyntheticWorkload.Create(10, 42), 0));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<IsPackable>false</IsPackable>
|
||||
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
|
||||
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../ProjectQuint.Core/ProjectQuint.Core.csproj" />
|
||||
<ProjectReference Include="../ProjectQuint.Probes/ProjectQuint.Probes.csproj" />
|
||||
<!-- NUnit 3 matches the assertions supported by the Unity test runner. -->
|
||||
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="6.3.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Tests/Core/**/*.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Updates/*.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs" />
|
||||
<Compile Include="../../Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs" />
|
||||
<Compile Include="CoreReplayTests.cs" />
|
||||
<Compile Include="CoreBenchmarkTests.cs" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"version": 1,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Microsoft.NET.Test.Sdk": {
|
||||
"type": "Direct",
|
||||
"requested": "[18.9.0, )",
|
||||
"resolved": "18.9.0",
|
||||
"contentHash": "xIzVXa/VpKXqDWzyC/5Hw8JbFY5u9k3Jc53CpcjBiOXvkQGio484LD9FNwOiGUSMDcYL3Rcw9sNgGi5WrswlPQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.CodeCoverage": "18.9.0",
|
||||
"Microsoft.TestPlatform.TestHost": "18.9.0"
|
||||
}
|
||||
},
|
||||
"NUnit": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.14.0, )",
|
||||
"resolved": "3.14.0",
|
||||
"contentHash": "R7iPwD7kbOaP3o2zldWJbWeMQAvDKD0uld27QvA3PAALl1unl7x0v2J7eGiJOYjimV/BuGT4VJmr45RjS7z4LA==",
|
||||
"dependencies": {
|
||||
"NETStandard.Library": "2.0.0"
|
||||
}
|
||||
},
|
||||
"NUnit3TestAdapter": {
|
||||
"type": "Direct",
|
||||
"requested": "[6.3.0, )",
|
||||
"resolved": "6.3.0",
|
||||
"contentHash": "8ZD+0dIfDHsL4w4Gl/LPxWH0O4ky3hFXqTZjY+ry0ThI3cMVErr9Tjxal5O5wJxGXh50a9qkDEAWVca8KQYCEw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyModel": "8.0.2",
|
||||
"Microsoft.Testing.Extensions.VSTestBridge": "2.3.3",
|
||||
"Microsoft.Testing.Platform.MSBuild": "2.3.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.ApplicationInsights": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.23.0",
|
||||
"contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw=="
|
||||
},
|
||||
"Microsoft.CodeCoverage": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.9.0",
|
||||
"contentHash": "MtegCIKGuG0r/LCdIjoR1HgzCGIUBhR3aNdbtQpts5vMXQuqBDZ2Jsd2uFKoHFM2XrQV6ZrDf3F5oLi3SLTp8w=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "mUBDZZRgZrSyFOsJ2qJJ9fXfqd/kXJwf3AiDoqLD9m6TjY5OO/vLNOb9fb4juC0487eq4hcGN/M2Rh/CKS7QYw=="
|
||||
},
|
||||
"Microsoft.NETCore.Platforms": {
|
||||
"type": "Transitive",
|
||||
"resolved": "1.1.0",
|
||||
"contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A=="
|
||||
},
|
||||
"Microsoft.Testing.Extensions.Telemetry": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.3",
|
||||
"contentHash": "nY8ceQyPWB9TRE1WE5Oe/sks2e10SxgPv61vBHsFYpgCeDtNwhpMZwmL29GklO3/etTdOsLdrCmNr4zJWaR2fg==",
|
||||
"dependencies": {
|
||||
"Microsoft.ApplicationInsights": "2.23.0",
|
||||
"Microsoft.Testing.Platform": "2.3.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.3",
|
||||
"contentHash": "dceVNxnfTEjKnrIreLcMfkgrzzBY8kgCCJX5NAv7Lq/6vPlTP4VB5zxKeuYy0yfCoBtWuPkLjUG6qtOBMfpypA==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "2.3.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Extensions.VSTestBridge": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.3",
|
||||
"contentHash": "JLovZjH6K10YiHO/BcidsaSHo/M2DfOe9FDjxjFdV6aoHO6VYTTYlLBNcyLJSo20q5gDTmNvmxYVH39A942aJA==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "18.4.0",
|
||||
"Microsoft.Testing.Extensions.Telemetry": "2.3.3",
|
||||
"Microsoft.Testing.Extensions.TrxReport.Abstractions": "2.3.3",
|
||||
"Microsoft.Testing.Platform": "2.3.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.Testing.Platform": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.3",
|
||||
"contentHash": "ENbH4BQh9riXtOKc25KKITfiGGWhWMBJA7pZuNaF9zxzzSaVkp5AMeZxGQ6sXYaR6xb724NLz985Is6XIYqLSg=="
|
||||
},
|
||||
"Microsoft.Testing.Platform.MSBuild": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.3.3",
|
||||
"contentHash": "iVAvNbZ5JPDZSrTcIrCDoCsAkzjDxwDEt2mDuMd8ng1P6e2P2NCd0g0BsoBVG+nJLSmK//V+yeEvjCa3E2fxHw==",
|
||||
"dependencies": {
|
||||
"Microsoft.Testing.Platform": "2.3.3"
|
||||
}
|
||||
},
|
||||
"Microsoft.TestPlatform.ObjectModel": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.9.0",
|
||||
"contentHash": "Fz/qXC52VXXopzHj7v2reCKcCqSxkTDCkEDfkNAH0vni/7qK/KLMiEYtRmnUanxO2F2g2zZZ60qCpxF0AF7URA=="
|
||||
},
|
||||
"Microsoft.TestPlatform.TestHost": {
|
||||
"type": "Transitive",
|
||||
"resolved": "18.9.0",
|
||||
"contentHash": "Oq+Pma/J86aZL72t71bcESvDszDsTjUfl6PIsuDdPoKTHZfNMhKamCfLD5fKx51W/u0KozXtJo2/3fnt0sgPxg==",
|
||||
"dependencies": {
|
||||
"Microsoft.TestPlatform.ObjectModel": "18.9.0"
|
||||
}
|
||||
},
|
||||
"NETStandard.Library": {
|
||||
"type": "Transitive",
|
||||
"resolved": "2.0.0",
|
||||
"contentHash": "7jnbRU+L08FXKMxqUflxEXtVymWvNOrS8yHgu9s6EM8Anr6T/wIX4nZ08j/u3Asz+tCufp3YVwFSEvFTPYmBPA==",
|
||||
"dependencies": {
|
||||
"Microsoft.NETCore.Platforms": "1.1.0"
|
||||
}
|
||||
},
|
||||
"projectquint.core": {
|
||||
"type": "Project"
|
||||
},
|
||||
"projectquint.probes": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"ProjectQuint.Core": "[1.0.0, )"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.100",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"complete": true,
|
||||
"measuredAtUtc": "2026-09-06T09:03:11.722784+00:00",
|
||||
"runtime": ".NET 10.0.11",
|
||||
"operatingSystem": "macOS",
|
||||
"architecture": "Arm64",
|
||||
"processorCount": 18,
|
||||
"notes": 10000,
|
||||
"events": 18822,
|
||||
"seed": 42,
|
||||
"frameRate": 240,
|
||||
"warmupIterations": 3,
|
||||
"requestedIterations": 20,
|
||||
"samples": [
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.082417,
|
||||
"replayMilliseconds": 5.242875,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.023084,
|
||||
"replayMilliseconds": 4.90025,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 2.797084,
|
||||
"replayMilliseconds": 4.959,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.082833,
|
||||
"replayMilliseconds": 5.050167,
|
||||
"setupAllocatedBytes": 1544944,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.052375,
|
||||
"replayMilliseconds": 5.015958,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.025,
|
||||
"replayMilliseconds": 5.027041,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 2.213917,
|
||||
"replayMilliseconds": 4.778833,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.857042,
|
||||
"replayMilliseconds": 4.818875,
|
||||
"setupAllocatedBytes": 1544944,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 1.114583,
|
||||
"replayMilliseconds": 4.960875,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.967792,
|
||||
"replayMilliseconds": 5.051125,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.947458,
|
||||
"replayMilliseconds": 4.945833,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.920375,
|
||||
"replayMilliseconds": 4.749292,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.959333,
|
||||
"replayMilliseconds": 4.844083,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 2.568125,
|
||||
"replayMilliseconds": 4.732708,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.869875,
|
||||
"replayMilliseconds": 4.813791,
|
||||
"setupAllocatedBytes": 1544944,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.891,
|
||||
"replayMilliseconds": 4.777333,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.864792,
|
||||
"replayMilliseconds": 4.674875,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.845208,
|
||||
"replayMilliseconds": 4.7745,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 0.846,
|
||||
"replayMilliseconds": 4.753083,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
},
|
||||
{
|
||||
"judgement": {
|
||||
"score": 944376,
|
||||
"combo": 5,
|
||||
"maxCombo": 23,
|
||||
"missCount": 825,
|
||||
"koolCount": 11905,
|
||||
"coolCount": 0,
|
||||
"goodCount": 0,
|
||||
"judgedUnits": 12730,
|
||||
"finished": true
|
||||
},
|
||||
"setupMilliseconds": 2.3905,
|
||||
"replayMilliseconds": 4.8165,
|
||||
"setupAllocatedBytes": 1544688,
|
||||
"replayAllocatedBytes": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
# 검증과 성능 측정
|
||||
|
||||
## 독립 테스트
|
||||
|
||||
.NET 10 SDK를 설치하고 저장소 루트에서 실행합니다.
|
||||
|
||||
```bash
|
||||
Tools/agentctl test
|
||||
```
|
||||
|
||||
기본 `dotnet` 대신 특정 실행 파일을 사용하려면 `PROJECTQUINT_DOTNET`을 지정합니다.
|
||||
Windows에서는 `Verification` 폴더에서 다음 명령을 직접 실행할 수 있습니다.
|
||||
|
||||
```bash
|
||||
dotnet test ProjectQuint.Tests/ProjectQuint.Tests.csproj -c Release -p:RestoreLockedMode=true
|
||||
```
|
||||
|
||||
패키지 버전과 간접 의존성은 `packages.lock.json`으로 고정합니다.
|
||||
처음 패키지를 복원할 때는 인터넷 연결이 필요하며, 복원 후에는 `--no-restore`를 추가할 수 있습니다.
|
||||
유니티 편집 모드와 .NET은 `Assets/ProjectQuint/Tests/Core`의 같은 테스트 파일을 사용합니다.
|
||||
홈 화면 이동·프로필 선택·업데이트 상태 테스트 역시 유니티와 독립 실행에서 공유합니다.
|
||||
|
||||
## 합성 입력 탐색
|
||||
|
||||
```bash
|
||||
Tools/agentctl list
|
||||
Tools/agentctl replay --notes 1000 --seed 42 --fps 30
|
||||
Tools/agentctl replay --notes 1000 --seed 42 --fps 240
|
||||
```
|
||||
|
||||
`replay`는 합성 노트와 미리 시각을 정한 입력 이벤트를 시간순으로 병합해 실행합니다.
|
||||
같은 노트 수와 시드에서 `result.judgement`를 비교하면 점수·콤보·판정 수·완료 여부를 확인할 수 있습니다.
|
||||
입력에는 일반 노트, 롱노트, 고의적인 미입력과 조기 놓기가 포함됩니다.
|
||||
가상 시간이므로 실제 곡 길이만큼 기다리지 않으며 음악이나 외부 BMS 폴더를 읽지 않습니다.
|
||||
|
||||
- `--notes`: 1부터 100,000까지의 노트 수입니다.
|
||||
- `--seed`: 0 이상의 정수입니다. 노트별 입력 시각 변화를 재현합니다.
|
||||
- `--fps`: 1부터 10,000까지의 가상 갱신 빈도입니다. 실제 FPS 측정값이 아닙니다.
|
||||
- `--output`: 결과 JSON을 저장할 경로입니다. 상대 경로는 `Verification` 폴더를 기준으로 합니다.
|
||||
|
||||
`list`, `replay`, `benchmark`의 표준 출력은 JSON이며 빌드 진단과 진행률은 표준 오류로 분리합니다.
|
||||
잘못된 인수와 실행 실패는 0이 아닌 종료 코드를 반환합니다.
|
||||
검증 도구는 `Assets` 바깥에 있으므로 유니티 게임 빌드에 포함되지 않습니다.
|
||||
이 도구는 코어 검증용이며 전체 게임 화면을 조작하는 인터페이스는 아닙니다.
|
||||
|
||||
## 성능 측정 재현
|
||||
|
||||
```bash
|
||||
Tools/agentctl benchmark --notes 10000 --seed 42 --fps 240 --iterations 20 \
|
||||
--output ../_workspace/core-benchmark.json
|
||||
```
|
||||
|
||||
워밍업 3회 뒤 요청한 횟수만큼 실행합니다. `--iterations`의 허용 범위는 1부터 100입니다.
|
||||
각 회차마다 진행률을 표시하고, 출력 경로를 지정하면 완료된 샘플을 중간 저장합니다.
|
||||
모든 회차를 마친 결과에만 `complete: true`가 기록됩니다.
|
||||
측정 중 중단된 결과를 최종 결과로 사용하지 않습니다.
|
||||
|
||||
원자료: [core-baseline.json](benchmarks/core-baseline.json).
|
||||
|
||||
| 항목 | 측정 조건 또는 결과 |
|
||||
| --- | --- |
|
||||
| 측정일 | 2026-09-06 |
|
||||
| 실행 환경 | macOS ARM64, .NET 10.0.11, 프로세스에서 관측한 논리 프로세서 18개 |
|
||||
| 입력 | 노트 10,000개, 입력 이벤트 18,822개, 시드 42 |
|
||||
| 가상 갱신 빈도 | 초당 240회 |
|
||||
| 반복 | 워밍업 3회, 본 측정 20회 |
|
||||
| 엔진 생성 시간 | 중앙값 0.995 ms, 최솟값 0.845 ms, 최댓값 2.797 ms |
|
||||
| 전체 입력 재생 시간 | 중앙값 4.831 ms, 최솟값 4.675 ms, 최댓값 5.243 ms |
|
||||
| 엔진 생성 중 관리 메모리 할당 | 중앙값 1,544,688바이트 |
|
||||
| 입력 재생 중 관리 메모리 추가 할당 | 20회 모두 0바이트 |
|
||||
|
||||
시간은 `Stopwatch`로, 할당량은 `GC.GetAllocatedBytesForCurrentThread`로 측정했습니다.
|
||||
합성 입력 생성과 결과 JSON 직렬화는 측정 구간에서 제외합니다.
|
||||
엔진 생성과 입력 재생을 나눠 기록하므로 준비 비용을 무시하지 않습니다.
|
||||
각 회차의 판정 결과가 워밍업과 같은지 확인하고, 다르면 측정을 실패 처리합니다.
|
||||
|
||||
이는 현재 구현의 기준 측정이지 최적화 전후 비교가 아닙니다.
|
||||
JIT 상태와 다른 프로세스의 부하에 따라 시간은 달라질 수 있으며 절대 시간으로 테스트를 실패시키지 않습니다.
|
||||
관리 메모리 측정에는 다른 스레드·네이티브 할당이 포함되지 않습니다.
|
||||
화면 렌더링·오디오 출력·앱의 판정 이벤트 구독자도 실행하지 않으므로,
|
||||
이 결과로 실제 게임 전체의 무할당 동작이나 입력 지연을 주장할 수 없습니다.
|
||||
|
||||
## 유니티와 실제 플레이 검증
|
||||
|
||||
유니티 `6000.3.20f1`에서 `EditMode` 테스트를 실행합니다.
|
||||
명령줄에서는 `-runTests -testPlatform EditMode -testResults <파일> -logFile <파일>`을 사용하며
|
||||
`-quit`는 함께 전달하지 않습니다. 에디터 활성화와 프로젝트 의존성 복원이 필요합니다.
|
||||
외부 BMS 자료가 필요한 테스트는 별도의 환경 변수로 경로를 받습니다.
|
||||
|
||||
독립 검증은 실제 FMOD 출력, 장치 전환, 화면 조작과 파일 선택 창을 검증하지 않습니다.
|
||||
음악을 재배포하지 않는 범위에서 실제 플레이로 로딩 취소·다시 시작·장치 변경·프로필 화면을 확인해야 합니다.
|
||||
자동화 서버에서의 연속 실행과 실제 플레이어 성능 측정은 별도 운영 범위입니다.
|
||||
|
||||
2026-09-06 검증에서는 독립 테스트 142개와 배포 도구 테스트 9개가 통과했습니다.
|
||||
유니티 컴파일러를 이용한 어셈블리 11개의 컴파일도 확인했습니다.
|
||||
유니티 Test Runner는 유효한 에디터 라이선스가 없어 테스트 시작 전에 종료되었으므로,
|
||||
편집 모드 전체 테스트와 실제 플레이 검증은 완료된 것으로 기록하지 않습니다.
|
||||
|
||||
앱의 업데이트와 프로필 상태는 독립 컨트롤러가 소유하지만,
|
||||
팩 관리·오디오 설정·화면 연결을 조정하는 코드는 아직 큰 앱 클래스에 남아 있습니다.
|
||||
추가 분리 시에는 관련 동작 테스트를 확보하고 같은 검증 절차를 반복합니다.
|
||||
Reference in New Issue
Block a user