diff --git a/.gitignore b/.gitignore index 68c1cbe..7ee8a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,10 @@ *.csproj *.sln *.slnx +!/Verification/**/*.csproj +/Verification/**/bin/ +/Verification/**/obj/ +/Verification/**/TestResults/ *.user *.userprefs diff --git a/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs b/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs new file mode 100644 index 0000000..87dc1ef --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs @@ -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; } + } + +} diff --git a/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs.meta b/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs.meta new file mode 100644 index 0000000..ead7b82 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/UpdateContracts.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9ccf2a029bb949a9a0962f8df10627a4 diff --git a/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs b/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs new file mode 100644 index 0000000..51cb02e --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs @@ -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; + } + + } +} diff --git a/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs.meta b/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs.meta new file mode 100644 index 0000000..e55275c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Core/Release/UpdatePolicy.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 8bcdf0d1dd20443ea0f4d55eb2954005 diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs new file mode 100644 index 0000000..0513a75 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs @@ -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 Songs { get; private set; } = Array.Empty(); + public int Revision { get; private set; } + + public void Refresh(IReadOnlyList contexts, + IReadOnlyList activeSongs, int songsRevision, BmsPackRegistry registry) + { + var requestedChartIds = new HashSet(StringComparer.Ordinal); + foreach (var context in contexts ?? Array.Empty()) + { + foreach (var entry in context.BestPlays ?? Array.Empty()) + { + 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()) + { + 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(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()) + AddSong(song); + foreach (var pack in registry?.Packs ?? Array.Empty()) + { + foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries( + registry.CachePathFor(pack), + pack.ContentRoot, + pack.Id, + requestedChartIds)) + AddSong(song); + } + + Songs = songsByChartId.Values.ToArray(); + _sourceSignature = signature; + unchecked + { + Revision++; + } + } + + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs.meta new file mode 100644 index 0000000..363a95c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProfileRatingThumbnailCatalog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: eaab54db67674a42ba9d968467641b78 diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs index fef8199..c0649cb 100644 --- a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintApp.cs @@ -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 _ratingContexts = Array.Empty(); - private int _profileRatingContextIndex; - private IReadOnlyList _profileRatingThumbnailSongs = - Array.Empty(); - 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(StringComparer.Ordinal); - foreach (var context in _ratingContexts ?? Array.Empty()) - { - foreach (var entry in context.BestPlays ?? Array.Empty()) - { - 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()) - { - 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(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()) - AddSong(song); - foreach (var pack in _packRegistry?.Packs ?? Array.Empty()) - { - 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 BuildProfileRatingContexts( - IReadOnlyList recordedContexts, - IReadOnlyList registeredTables, - IReadOnlyList activeSongs, - string activeTableId, - ProfileProgressSnapshot profile, - Func resolveRating) - { - var contexts = new Dictionary(StringComparer.OrdinalIgnoreCase); - var tableNames = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var table in registeredTables ?? Array.Empty()) - { - 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()) - { - if (!recorded.HasContext) - continue; - contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded; - } - - foreach (var table in registeredTables ?? Array.Empty()) - { - 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()) - .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 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(); diff --git a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs index c8de287..b0618a9 100644 --- a/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs +++ b/Assets/ProjectQuint/Runtime/Unity/Bootstrap/ProjectQuintUpdateService.cs @@ -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)) diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs index 4f1904b..8ebb47e 100644 --- a/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Flow/AppFlowController.cs @@ -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; diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Profile.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Profile.meta new file mode 100644 index 0000000..c3a9237 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Profile.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: a699850f7cb543659720ced37887b8b6 +folderAsset: yes diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs new file mode 100644 index 0000000..6d6a867 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs @@ -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 Build( + IReadOnlyList recordedContexts, + IReadOnlyList registeredTables, + IReadOnlyList activeSongs, + string activeTableId, + ProfileProgressSnapshot profile, + Func resolveRating) + { + var contexts = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tableNames = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var table in registeredTables ?? Array.Empty()) + { + 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()) + { + if (!recorded.HasContext) + continue; + contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded; + } + + foreach (var table in registeredTables ?? Array.Empty()) + { + 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()) + .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}"; + + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs.meta new file mode 100644 index 0000000..cf28bb1 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileRatingContextBuilder.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 65c9c48983064911802938aef1b8ae09 diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs new file mode 100644 index 0000000..d74a4b8 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using ProjectQuint.Core.Progression; + +namespace ProjectQuint.Features.Profile +{ + /// Owns profile navigation independently of the input device, UI and storage. + 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 RatingContexts { get; private set; } = Array.Empty(); + 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 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 ratingContexts, RatingSummary fallbackRating) + { + Snapshot = snapshot; + Collection = collection ?? ProfileCollectionSnapshot.Empty; + RatingContexts = ratingContexts ?? Array.Empty(); + // 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 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 + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs.meta new file mode 100644 index 0000000..c23514c --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Profile/ProfileSessionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3d53feec096341b8a67e3bf4404774dd diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Updates.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Updates.meta new file mode 100644 index 0000000..4d96d48 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Updates.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: fee265429b204c6fbc7524f90b8c438d +folderAsset: yes diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs b/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs new file mode 100644 index 0000000..a7fcc64 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs @@ -0,0 +1,131 @@ +using ProjectQuint.Core.Release; +using ProjectQuint.Features.Flow; + +namespace ProjectQuint.Features.Updates +{ + /// + /// 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. + /// + 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 + } +} diff --git a/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs.meta b/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs.meta new file mode 100644 index 0000000..c2e8328 --- /dev/null +++ b/Assets/ProjectQuint/Runtime/Unity/Features/Updates/UpdateSessionController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 3f678ce98b05486bac254d968c77eddf diff --git a/Assets/ProjectQuint/Tests/Core.meta b/Assets/ProjectQuint/Tests/Core.meta new file mode 100644 index 0000000..3e75ff5 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 9a1a356268ef4303b32df7e8fdc9e78f +folderAsset: yes diff --git a/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs b/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs new file mode 100644 index 0000000..9be7774 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs @@ -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(); + 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() + .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() + .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)); + } + + } +} diff --git a/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs.meta b/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs.meta new file mode 100644 index 0000000..c22684c --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/BmsParsingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 11e00ecd33ce4a44b4ba5ae35921a2b9 diff --git a/Assets/ProjectQuint/Tests/EditMode/ChartFeatureAnalyzerTests.cs b/Assets/ProjectQuint/Tests/Core/ChartFeatureAnalyzerTests.cs similarity index 100% rename from Assets/ProjectQuint/Tests/EditMode/ChartFeatureAnalyzerTests.cs rename to Assets/ProjectQuint/Tests/Core/ChartFeatureAnalyzerTests.cs diff --git a/Assets/ProjectQuint/Tests/Core/ChartFeatureAnalyzerTests.cs.meta b/Assets/ProjectQuint/Tests/Core/ChartFeatureAnalyzerTests.cs.meta new file mode 100644 index 0000000..e34b6af --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/ChartFeatureAnalyzerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 551153b8a58bd4ddd8daf9dd5dcde978 diff --git a/Assets/ProjectQuint/Tests/EditMode/CourseLaneModifierPolicyTests.cs b/Assets/ProjectQuint/Tests/Core/CourseLaneModifierPolicyTests.cs similarity index 100% rename from Assets/ProjectQuint/Tests/EditMode/CourseLaneModifierPolicyTests.cs rename to Assets/ProjectQuint/Tests/Core/CourseLaneModifierPolicyTests.cs diff --git a/Assets/ProjectQuint/Tests/Core/CourseLaneModifierPolicyTests.cs.meta b/Assets/ProjectQuint/Tests/Core/CourseLaneModifierPolicyTests.cs.meta new file mode 100644 index 0000000..3654769 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/CourseLaneModifierPolicyTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ff984ac8f483b429aace8d868437ea96 diff --git a/Assets/ProjectQuint/Tests/Core/GaugeTests.cs b/Assets/ProjectQuint/Tests/Core/GaugeTests.cs new file mode 100644 index 0000000..a8afdd1 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/GaugeTests.cs @@ -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)); + } + + } +} diff --git a/Assets/ProjectQuint/Tests/Core/GaugeTests.cs.meta b/Assets/ProjectQuint/Tests/Core/GaugeTests.cs.meta new file mode 100644 index 0000000..08b881f --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/GaugeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 443a38441d2c477db7e4983a4b938f83 diff --git a/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs b/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs new file mode 100644 index 0000000..0a8860d --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs @@ -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)); + } + } +} diff --git a/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs.meta b/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs.meta new file mode 100644 index 0000000..e0ae22a --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/MusicalTimingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 32a7895b9d504aa8bdbe439bebff65c7 diff --git a/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef b/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef new file mode 100644 index 0000000..a5236c5 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef @@ -0,0 +1,8 @@ +{ + "name": "ProjectQuint.Tests.Core", + "references": ["ProjectQuint.Core"], + "includePlatforms": ["Editor"], + "optionalUnityReferences": ["TestAssemblies"], + "autoReferenced": false, + "noEngineReferences": true +} diff --git a/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef.meta b/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef.meta new file mode 100644 index 0000000..a2c637f --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/ProjectQuint.Tests.Core.asmdef.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 95d4580a877742e9ad20db382f8d33d3 diff --git a/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs b/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs new file mode 100644 index 0000000..cc95e53 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs @@ -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(); + 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)); + } + + } +} diff --git a/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs.meta b/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs.meta new file mode 100644 index 0000000..582db30 --- /dev/null +++ b/Assets/ProjectQuint/Tests/Core/RhythmJudgementTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a0d49a1c82734afea812ffafb41ce7b6 diff --git a/Assets/ProjectQuint/Tests/EditMode/ArchitectureBoundaryTests.cs b/Assets/ProjectQuint/Tests/EditMode/ArchitectureBoundaryTests.cs index a75a759..c7ec6ac 100644 --- a/Assets/ProjectQuint/Tests/EditMode/ArchitectureBoundaryTests.cs +++ b/Assets/ProjectQuint/Tests/EditMode/ArchitectureBoundaryTests.cs @@ -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() { diff --git a/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs b/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs deleted file mode 100644 index bfc9ada..0000000 --- a/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs +++ /dev/null @@ -1,1451 +0,0 @@ -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 BmsCoreTests - { - [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(); - 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() - .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() - .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 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)); - } - - [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 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 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 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)); - } - - [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 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(); - 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)); - } - - [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)); - } - } -} diff --git a/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs.meta deleted file mode 100644 index 2b3bb7f..0000000 --- a/Assets/ProjectQuint/Tests/EditMode/BmsCoreTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6613cb9e0883448468b1bfa7e788894e \ No newline at end of file diff --git a/Assets/ProjectQuint/Tests/EditMode/ChartFeatureAnalyzerTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/ChartFeatureAnalyzerTests.cs.meta deleted file mode 100644 index 1bf8609..0000000 --- a/Assets/ProjectQuint/Tests/EditMode/ChartFeatureAnalyzerTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 551153b8a58bd4ddd8daf9dd5dcde978 \ No newline at end of file diff --git a/Assets/ProjectQuint/Tests/EditMode/CourseLaneModifierPolicyTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/CourseLaneModifierPolicyTests.cs.meta deleted file mode 100644 index 043a1a1..0000000 --- a/Assets/ProjectQuint/Tests/EditMode/CourseLaneModifierPolicyTests.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: ff984ac8f483b429aace8d868437ea96 \ No newline at end of file diff --git a/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs b/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs new file mode 100644 index 0000000..ebd4875 --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs @@ -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)); + } + } +} diff --git a/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs.meta new file mode 100644 index 0000000..f10813a --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/HomeNavigationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d5440faaa6cf473bb016ed8a641239b2 diff --git a/Assets/ProjectQuint/Tests/EditMode/ProfileProgressionTests.cs b/Assets/ProjectQuint/Tests/EditMode/ProfileProgressionTests.cs index 13f6af9..7b17bce 100644 --- a/Assets/ProjectQuint/Tests/EditMode/ProfileProgressionTests.cs +++ b/Assets/ProjectQuint/Tests/EditMode/ProfileProgressionTests.cs @@ -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(), diff --git a/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs b/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs new file mode 100644 index 0000000..82a629e --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs @@ -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(), 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); + } + } +} diff --git a/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs.meta new file mode 100644 index 0000000..b95f053 --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/ProfileSessionControllerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc915c84e7104cebbd016972b39e1362 diff --git a/Assets/ProjectQuint/Tests/EditMode/ProjectQuintUpdateServiceTests.cs b/Assets/ProjectQuint/Tests/EditMode/ProjectQuintUpdateServiceTests.cs index 61490af..29707dd 100644 --- a/Assets/ProjectQuint/Tests/EditMode/ProjectQuintUpdateServiceTests.cs +++ b/Assets/ProjectQuint/Tests/EditMode/ProjectQuintUpdateServiceTests.cs @@ -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] diff --git a/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs b/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs new file mode 100644 index 0000000..feb45a7 --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs @@ -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); + } + } +} diff --git a/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs.meta b/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs.meta new file mode 100644 index 0000000..4657ce1 --- /dev/null +++ b/Assets/ProjectQuint/Tests/EditMode/UpdateSessionControllerTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cd82750b60d84a0f980a0721221c1f9d diff --git a/README.md b/README.md index 6c3e29c..53a4824 100644 --- a/README.md +++ b/README.md @@ -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`는 함께 전달하지 않습니다. diff --git a/Tools/agentctl b/Tools/agentctl new file mode 100755 index 0000000..67db4c1 --- /dev/null +++ b/Tools/agentctl @@ -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 "$@" diff --git a/Verification/ProjectQuint.Agent/Program.cs b/Verification/ProjectQuint.Agent/Program.cs new file mode 100644 index 0000000..7203b1f --- /dev/null +++ b/Verification/ProjectQuint.Agent/Program.cs @@ -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(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); +} diff --git a/Verification/ProjectQuint.Agent/ProjectQuint.Agent.csproj b/Verification/ProjectQuint.Agent/ProjectQuint.Agent.csproj new file mode 100644 index 0000000..59a8e2b --- /dev/null +++ b/Verification/ProjectQuint.Agent/ProjectQuint.Agent.csproj @@ -0,0 +1,12 @@ + + + Exe + net10.0 + enable + enable + true + + + + + diff --git a/Verification/ProjectQuint.Core/ProjectQuint.Core.csproj b/Verification/ProjectQuint.Core/ProjectQuint.Core.csproj new file mode 100644 index 0000000..2c00a71 --- /dev/null +++ b/Verification/ProjectQuint.Core/ProjectQuint.Core.csproj @@ -0,0 +1,12 @@ + + + netstandard2.1 + 9.0 + false + true + + + + + + diff --git a/Verification/ProjectQuint.Probes/CoreBenchmark.cs b/Verification/ProjectQuint.Probes/CoreBenchmark.cs new file mode 100644 index 0000000..1c487cf --- /dev/null +++ b/Verification/ProjectQuint.Probes/CoreBenchmark.cs @@ -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 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(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()); + } +} diff --git a/Verification/ProjectQuint.Probes/ProjectQuint.Probes.csproj b/Verification/ProjectQuint.Probes/ProjectQuint.Probes.csproj new file mode 100644 index 0000000..a2e9612 --- /dev/null +++ b/Verification/ProjectQuint.Probes/ProjectQuint.Probes.csproj @@ -0,0 +1,11 @@ + + + net10.0 + enable + enable + true + + + + + diff --git a/Verification/ProjectQuint.Probes/ReplayRunner.cs b/Verification/ProjectQuint.Probes/ReplayRunner.cs new file mode 100644 index 0000000..e969fb6 --- /dev/null +++ b/Verification/ProjectQuint.Probes/ReplayRunner.cs @@ -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; +} diff --git a/Verification/ProjectQuint.Probes/SyntheticWorkload.cs b/Verification/ProjectQuint.Probes/SyntheticWorkload.cs new file mode 100644 index 0000000..2c04da0 --- /dev/null +++ b/Verification/ProjectQuint.Probes/SyntheticWorkload.cs @@ -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(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); + } +} diff --git a/Verification/ProjectQuint.Tests/CoreBenchmarkTests.cs b/Verification/ProjectQuint.Tests/CoreBenchmarkTests.cs new file mode 100644 index 0000000..99f6012 --- /dev/null +++ b/Verification/ProjectQuint.Tests/CoreBenchmarkTests.cs @@ -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(() => + CoreBenchmark.Run(100, 42, 60, iterations, _ => { })); + } +} diff --git a/Verification/ProjectQuint.Tests/CoreReplayTests.cs b/Verification/ProjectQuint.Tests/CoreReplayTests.cs new file mode 100644 index 0000000..87a699f --- /dev/null +++ b/Verification/ProjectQuint.Tests/CoreReplayTests.cs @@ -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(() => SyntheticWorkload.Create(notes, 42)); + + [Test] + public void InvalidFrameRate_IsRejected() => + Assert.Throws(() => + ReplayRunner.Run(SyntheticWorkload.Create(10, 42), 0)); + } +} diff --git a/Verification/ProjectQuint.Tests/ProjectQuint.Tests.csproj b/Verification/ProjectQuint.Tests/ProjectQuint.Tests.csproj new file mode 100644 index 0000000..ac99f2d --- /dev/null +++ b/Verification/ProjectQuint.Tests/ProjectQuint.Tests.csproj @@ -0,0 +1,28 @@ + + + net10.0 + 9.0 + true + false + false + true + true + + + + + + + + + + + + + + + + + + + diff --git a/Verification/ProjectQuint.Tests/packages.lock.json b/Verification/ProjectQuint.Tests/packages.lock.json new file mode 100644 index 0000000..b53a409 --- /dev/null +++ b/Verification/ProjectQuint.Tests/packages.lock.json @@ -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, )" + } + } + } + } +} \ No newline at end of file diff --git a/Verification/global.json b/Verification/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/Verification/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/docs/benchmarks/core-baseline.json b/docs/benchmarks/core-baseline.json new file mode 100644 index 0000000..e2e3cae --- /dev/null +++ b/docs/benchmarks/core-baseline.json @@ -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 + } + ] +} diff --git a/docs/verification.md b/docs/verification.md new file mode 100644 index 0000000..9f97349 --- /dev/null +++ b/docs/verification.md @@ -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는 유효한 에디터 라이선스가 없어 테스트 시작 전에 종료되었으므로, +편집 모드 전체 테스트와 실제 플레이 검증은 완료된 것으로 기록하지 않습니다. + +앱의 업데이트와 프로필 상태는 독립 컨트롤러가 소유하지만, +팩 관리·오디오 설정·화면 연결을 조정하는 코드는 아직 큰 앱 클래스에 남아 있습니다. +추가 분리 시에는 관련 동작 테스트를 확보하고 같은 검증 절차를 반복합니다.