독립 검증 도구 추가와 앱 상태 책임 분리

This commit is contained in:
2026-09-06 18:09:11 +09:00
parent 0f0431de5a
commit 002a922e1b
62 changed files with 3454 additions and 2008 deletions
@@ -0,0 +1,37 @@
using System;
namespace ProjectQuint.Core.Release
{
[Serializable]
public sealed class ProjectQuintUpdateManifest
{
public int schemaVersion;
public string channel = string.Empty;
public bool available;
public string version = string.Empty;
public int buildNumber;
public string publishedAt = string.Empty;
public string downloadUrl = string.Empty;
public string notesUrl = string.Empty;
public string sha256 = string.Empty;
public long sizeBytes;
}
public readonly struct ProjectQuintUpdateCheckResult
{
public ProjectQuintUpdateCheckResult(
bool succeeded,
ProjectQuintUpdateManifest manifest,
string error)
{
Succeeded = succeeded;
Manifest = manifest;
Error = error ?? string.Empty;
}
public bool Succeeded { get; }
public ProjectQuintUpdateManifest Manifest { get; }
public string Error { get; }
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 9ccf2a029bb949a9a0962f8df10627a4
@@ -0,0 +1,35 @@
using System;
namespace ProjectQuint.Core.Release
{
public static class UpdatePolicy
{
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
manifest != null &&
manifest.available &&
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
public static bool ShouldOffer(
ProjectQuintUpdateManifest manifest,
string skippedVersion,
bool manual) =>
IsUpdateAvailable(manifest) &&
(manual ||
!string.Equals(
manifest.version,
skippedVersion?.Trim(),
StringComparison.OrdinalIgnoreCase));
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
{
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
return true;
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
AutomaticCheckInterval;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 8bcdf0d1dd20443ea0f4d55eb2954005
@@ -0,0 +1,79 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ProjectQuint.Core.Progression;
using ProjectQuint.Library;
using ProjectQuint.Persistence;
namespace ProjectQuint.Bootstrap
{
internal sealed class ProfileRatingThumbnailCatalog
{
private long _sourceSignature = long.MinValue;
public IReadOnlyList<SongLibraryEntry> Songs { get; private set; } = Array.Empty<SongLibraryEntry>();
public int Revision { get; private set; }
public void Refresh(IReadOnlyList<RatingSummary> contexts,
IReadOnlyList<SongLibraryEntry> activeSongs, int songsRevision, BmsPackRegistry registry)
{
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var context in contexts ?? Array.Empty<RatingSummary>())
{
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
{
if (!string.IsNullOrWhiteSpace(entry.ChartId))
requestedChartIds.Add(entry.ChartId);
}
}
var signature = 17L;
unchecked
{
signature = signature * 31 + (songsRevision);
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
var cachePath = registry.CachePathFor(pack);
var cache = new FileInfo(cachePath);
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
}
}
if (signature == _sourceSignature)
return;
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
void AddSong(SongLibraryEntry song)
{
if (song == null)
return;
var chartId = ChartIdentityResolver.Resolve(song).Value;
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
songsByChartId.Add(chartId, song);
}
foreach (var song in activeSongs ?? Array.Empty<SongLibraryEntry>())
AddSong(song);
foreach (var pack in registry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
registry.CachePathFor(pack),
pack.ContentRoot,
pack.Id,
requestedChartIds))
AddSong(song);
}
Songs = songsByChartId.Values.ToArray();
_sourceSignature = signature;
unchecked
{
Revision++;
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: eaab54db67674a42ba9d968467641b78
@@ -11,7 +11,9 @@ using ProjectQuint.Features.Gameplay;
using ProjectQuint.Features.Library;
using ProjectQuint.Features.Loading;
using ProjectQuint.Features.Results;
using ProjectQuint.Features.Profile;
using ProjectQuint.Features.Settings;
using ProjectQuint.Features.Updates;
using ProjectQuint.Audio;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
@@ -138,31 +140,10 @@ namespace ProjectQuint.Bootstrap
private Texture2D _profileImageTexture;
private string _profileImageStatus = string.Empty;
private string _profileTransferStatus = "PROFILE ARCHIVE READY";
private string _updateStatus = "NOT CHECKED";
private ProjectQuintUpdateManifest _availableUpdate;
private bool _automaticUpdateCheckStarted;
private bool _updateCheckRunning;
private bool _updateOfferPending;
private bool _updateModalOpen;
private bool _updateDismissedThisRun;
private int _updateModalSelection;
private readonly UpdateSessionController _updateSession = new UpdateSessionController();
private bool _profileImportConfirmationPending;
private ProfileProgressSnapshot _profileSnapshot;
private RatingSummary _homeRatingSummary;
private RatingSummary _profileRatingSummary;
private IReadOnlyList<RatingSummary> _ratingContexts = Array.Empty<RatingSummary>();
private int _profileRatingContextIndex;
private IReadOnlyList<SongLibraryEntry> _profileRatingThumbnailSongs =
Array.Empty<SongLibraryEntry>();
private int _profileRatingThumbnailSongsRevision;
private long _profileRatingThumbnailSourceSignature = long.MinValue;
private ProfileCollectionSnapshot _profileCollection = ProfileCollectionSnapshot.Empty;
private ProfileCourseSnapshot _profileCourses = ProfileCourseSnapshot.Empty;
private bool _profileModalOpen;
private int _profileModalTab;
private int _profileBadgeSelection;
private int _profileCollectionColumn;
private readonly int[] _profileCollectionSelections = new int[3];
private readonly ProfileSessionController _profileSession = new ProfileSessionController();
private readonly ProfileRatingThumbnailCatalog _profileThumbnails = new ProfileRatingThumbnailCatalog();
private string _libraryRoot = string.Empty;
private string _libraryLoadError = string.Empty;
private bool _libraryReady;
@@ -205,8 +186,7 @@ namespace ProjectQuint.Bootstrap
private bool _autoScratchEnabled;
private bool _gameplayDiagnosticsEnabled;
// Compatibility accessors keep this first extraction behavior-neutral. Feature controllers
// can receive GameSettings directly as they are split out in later refactoring stages.
// These accessors share the persisted settings object; they do not keep local copies.
private Key[] _laneKeys => _settings.LaneKeys;
private Key _centerAuxKey => _settings.CenterAuxKey;
private bool _reactiveKeySoundsEnabled { get => _settings.ReactiveKeySoundsEnabled; set => _settings.ReactiveKeySoundsEnabled = value; }
@@ -854,7 +834,7 @@ namespace ProjectQuint.Bootstrap
_libraryThumbnail = null;
_stageTexture = null;
_profileSnapshot = currentProfile;
_profileSession.SetPreviewSnapshot(currentProfile);
_resultSession.BeginPlayback();
_resultSession.SetCommitOutcome(new PlayCommitOutcome(profileUpdate, ratingUpdate));
_resultSession.BeginResults(
@@ -872,9 +852,8 @@ namespace ProjectQuint.Bootstrap
{
var key = (surface ?? string.Empty).Trim().ToLowerInvariant();
var selectedSong = _libraryController?.State?.SelectedSong;
_updateModalOpen = false;
_updateOfferPending = false;
_profileModalOpen = false;
_updateSession.Cancel();
_profileSession.Close();
_packCacheManagerOpen = false;
_pinnedPackCacheConfirmationPending = false;
_packRemovalConfirmationPending = false;
@@ -894,7 +873,7 @@ namespace ProjectQuint.Bootstrap
_mode = AppMode.TeamDeniaBranding;
break;
case "update":
_availableUpdate = new ProjectQuintUpdateManifest
_updateSession.ShowPreview(new ProjectQuintUpdateManifest
{
schemaVersion = 1,
channel = ProjectQuintReleaseVersion.ReleaseChannel,
@@ -908,10 +887,7 @@ namespace ProjectQuint.Bootstrap
notesUrl = "/changelog/",
sha256 = new string('a', 64),
sizeBytes = 78_000_000,
};
_updateModalSelection = 0;
_updateOfferPending = false;
_updateModalOpen = true;
});
_mode = AppMode.Home;
break;
case "pack":
@@ -1120,7 +1096,7 @@ namespace ProjectQuint.Bootstrap
HandleExternalAudioConfigurationChange();
TryStartAutomaticUpdateCheck();
TryPresentPendingUpdate();
if (_updateModalOpen)
if (_updateSession.IsModalOpen)
{
UpdateUpdateNoticeInput();
FlushPendingLibraryViewState(force: false);
@@ -1194,15 +1170,15 @@ namespace ProjectQuint.Bootstrap
: Time.realtimeSinceStartupAsDouble - _teamDeniaBrandingEnteredRealtime;
_startupBrandingPresenter?.Update(_mode, startupElapsed);
var homeVisible = _mode == AppMode.Home && !_profileModalOpen;
var homeVisible = _mode == AppMode.Home && !_profileSession.IsOpen;
_homeScreenPresenter?.SetVisible(homeVisible);
_updateNoticePresenter?.SetVisible(_updateModalOpen);
if (_updateModalOpen && _availableUpdate != null)
_updateNoticePresenter?.SetVisible(_updateSession.IsModalOpen);
if (_updateSession.IsModalOpen && _updateSession.Manifest != null)
{
_updateNoticePresenter?.Update(
_availableUpdate.version,
_availableUpdate.buildNumber,
_updateModalSelection,
_updateSession.Manifest.version,
_updateSession.Manifest.buildNumber,
_updateSession.Selection,
_sparkleUpdater?.IsReady == true);
}
@@ -1277,7 +1253,7 @@ namespace ProjectQuint.Bootstrap
_resultSession.RatingUpdate,
_resultSession.RecordSaveError,
_resultSession.PlayedUtcTicks,
_profileSnapshot,
_profileSession.Snapshot,
_resultsSelection,
Math.Max(0.0, Time.realtimeSinceStartupAsDouble - _resultEnteredRealtime),
_gameplaySession?.IsAutoScratch == true);
@@ -1290,7 +1266,7 @@ namespace ProjectQuint.Bootstrap
_coursePassed,
_courseTrophy,
_courseBadgeEarned,
_profileSnapshot,
_profileSession.Snapshot,
_courseResultSelection);
break;
case AppMode.Error:
@@ -1374,7 +1350,7 @@ namespace ProjectQuint.Bootstrap
? _catalogMatchedCharts
: -1,
selectedTableDataAvailable,
_profileSnapshot,
_profileSession.Snapshot,
_status,
_packKeyModeModalOpen,
_packKeyModeModalSelection,
@@ -1439,7 +1415,7 @@ namespace ProjectQuint.Bootstrap
_gameplaySkin,
_bgaPlaybackMode,
_reactiveKeySoundsEnabled,
_profileSnapshot,
_profileSession.Snapshot,
library.ViewRevision);
break;
}
@@ -1453,7 +1429,7 @@ namespace ProjectQuint.Bootstrap
var optionsVisible = _mode == AppMode.Options;
var profileVisible =
_profileModalOpen &&
_profileSession.IsOpen &&
(_mode == AppMode.Home || _mode == AppMode.Library);
var courseVisible = _mode == AppMode.CourseSelect;
_optionsProfileCoursePresenter.SetOptionsVisible(optionsVisible);
@@ -1504,27 +1480,27 @@ namespace ProjectQuint.Bootstrap
GameSettings.FormatLaneKeyLabel(_settings.ScratchKey),
_rebindLane,
_profileTransferStatus,
_updateStatus,
_updateSession.Status,
_profileImportConfirmationPending,
_profileSnapshot,
_profileSession.Snapshot,
BuildAudioSyncCalibrationView());
}
if (profileVisible)
{
_optionsProfileCoursePresenter.UpdateProfile(
_profileSnapshot,
_profileRatingSummary,
_profileCollection,
_profileCourses,
_profileModalTab,
_profileRatingContextIndex,
_ratingContexts.Count,
_profileBadgeSelection,
_profileCollectionColumn,
_profileCollectionSelections,
_profileRatingThumbnailSongs,
_profileRatingThumbnailSongsRevision);
_profileSession.Snapshot,
_profileSession.RatingSummary,
_profileSession.Collection,
_profileSession.Courses,
_profileSession.Tab,
_profileSession.RatingContextIndex,
_profileSession.RatingContexts.Count,
_profileSession.BadgeSelection,
_profileSession.CollectionColumn,
_profileSession.CollectionSelections,
_profileThumbnails.Songs,
_profileThumbnails.Revision);
}
if (courseVisible)
@@ -1536,7 +1512,7 @@ namespace ProjectQuint.Bootstrap
EffectiveCourseModifier,
_noteSpeed,
_courseStatus,
_profileSnapshot);
_profileSession.Snapshot);
}
}
@@ -2178,14 +2154,8 @@ namespace ProjectQuint.Bootstrap
switch (action)
{
case HomeMenuAction.Play:
_packSelection = Mathf.Clamp(
ResolveActivePackSelection(),
0,
Math.Max(0, (_packRegistry?.Packs.Count ?? 0) + 1));
_status = _packRegistry?.Packs.Count > 0
? $"{_packRegistry.Packs.Count:N0} BMS PACKS REGISTERED"
: "SELECT A FOLDER OR ZIP TO BEGIN";
_mode = AppMode.PackSelect;
_flow.TryBeginPlayFromHome(
ResolveActivePackSelection(), _packRegistry?.Packs.Count ?? 0);
break;
case HomeMenuAction.Options:
OpenOptions(AppMode.Home);
@@ -4947,99 +4917,49 @@ namespace ProjectQuint.Bootstrap
private void TryStartAutomaticUpdateCheck()
{
if (_automaticUpdateCheckStarted ||
_settings == null ||
_mode != AppMode.Home)
return;
_automaticUpdateCheckStarted = true;
if (Application.isEditor)
return;
if (!ProjectQuintUpdateService.ShouldCheckAutomatically(
_settings.LastUpdateCheckUtcTicks,
DateTime.UtcNow.Ticks))
{
_updateStatus = "CHECKED RECENTLY";
return;
}
RequestUpdateCheck(manual: false);
if (_settings != null && _updateSession.TryBeginAutomaticCheck(
_mode, Application.isEditor, _settings.LastUpdateCheckUtcTicks,
DateTime.UtcNow.Ticks, out var generation))
StartCoroutine(CheckForUpdates(generation));
}
private void RequestUpdateCheck(bool manual)
{
if (_updateCheckRunning)
{
if (manual)
_updateStatus = "CHECKING...";
return;
}
if (manual)
{
_updateDismissedThisRun = false;
_updateOfferPending = false;
}
_updateStatus = "CHECKING...";
StartCoroutine(CheckForUpdates(manual));
if (_updateSession.TryBeginCheck(manual, out var generation))
StartCoroutine(CheckForUpdates(generation));
}
private IEnumerator CheckForUpdates(bool manual)
private IEnumerator CheckForUpdates(int generation)
{
_updateCheckRunning = true;
ProjectQuintUpdateCheckResult result = default;
yield return ProjectQuintUpdateService.Check(value => result = value);
_updateCheckRunning = false;
if (!result.Succeeded)
try
{
if (manual)
_updateStatus = "CHECK FAILED";
else
_updateStatus = "NOT CHECKED";
Debug.LogWarning($"[ProjectQuint Update] {result.Error}");
yield break;
ProjectQuintUpdateCheckResult result = default;
yield return ProjectQuintUpdateService.Check(value => result = value);
if (!_updateSession.CompleteCheck(generation, result,
_settings.SkippedUpdateVersion, DateTime.UtcNow.Ticks))
yield break;
if (!result.Succeeded || result.Manifest == null)
{
Debug.LogWarning($"[ProjectQuint Update] {_updateSession.Error}");
yield break;
}
_settings.LastUpdateCheckUtcTicks = _updateSession.LastSuccessfulCheckUtcTicks;
PersistSettings();
}
_settings.LastUpdateCheckUtcTicks = DateTime.UtcNow.Ticks;
PersistSettings();
_availableUpdate = result.Manifest;
if (!ProjectQuintUpdateService.IsUpdateAvailable(_availableUpdate))
finally
{
_updateStatus = $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}";
yield break;
_updateSession.AbortCheck(generation);
}
_updateStatus = $"AVAILABLE // {_availableUpdate.version.ToUpperInvariant()}";
if (ProjectQuintUpdateService.ShouldOffer(
_availableUpdate,
_settings.SkippedUpdateVersion,
manual))
_updateOfferPending = true;
}
private void TryPresentPendingUpdate()
{
if (!_updateOfferPending ||
_updateModalOpen ||
_updateDismissedThisRun ||
_availableUpdate == null)
return;
var safeHome = _mode == AppMode.Home && !_profileModalOpen;
var safeOptions =
_mode == AppMode.Options &&
_audioSyncCalibration == null &&
string.IsNullOrWhiteSpace(_audioSyncCalibrationError) &&
!_packCacheManagerOpen &&
!_profileImportConfirmationPending;
if (!safeHome && !safeOptions)
return;
_updateOfferPending = false;
_updateModalSelection = 0;
_updateModalOpen = true;
var blocked = (_mode == AppMode.Home && _profileSession.IsOpen) ||
(_mode == AppMode.Options &&
(_audioSyncCalibration != null ||
!string.IsNullOrWhiteSpace(_audioSyncCalibrationError) ||
_packCacheManagerOpen || _profileImportConfirmationPending));
_updateSession.TryPresent(_mode, blocked);
}
private void UpdateUpdateNoticeInput()
@@ -5056,19 +4976,19 @@ namespace ProjectQuint.Bootstrap
if (keyboard.leftArrowKey.wasPressedThisFrame ||
keyboard.upArrowKey.wasPressedThisFrame)
_updateModalSelection = (_updateModalSelection + 2) % 3;
_updateSession.MoveSelection(-1);
else if (keyboard.rightArrowKey.wasPressedThisFrame ||
keyboard.downArrowKey.wasPressedThisFrame)
_updateModalSelection = (_updateModalSelection + 1) % 3;
_updateSession.MoveSelection(1);
if (keyboard.enterKey.wasPressedThisFrame ||
keyboard.numpadEnterKey.wasPressedThisFrame)
HandleUpdateNoticeAction((UpdateNoticeAction)_updateModalSelection);
HandleUpdateNoticeAction((UpdateNoticeAction)_updateSession.Selection);
}
private void HandleUpdateNoticeAction(UpdateNoticeAction action)
{
if (!_updateModalOpen || _availableUpdate == null)
if (!_updateSession.IsModalOpen || _updateSession.Manifest == null)
return;
switch (action)
@@ -5078,7 +4998,7 @@ namespace ProjectQuint.Bootstrap
if (_sparkleUpdater != null &&
_sparkleUpdater.TryCheckForUpdates(out sparkleError))
{
_updateStatus = "OPENING SECURE UPDATE INSTALLER...";
_updateSession.MarkInstallerOpening();
}
else
{
@@ -5088,14 +5008,12 @@ namespace ProjectQuint.Bootstrap
}
break;
case UpdateNoticeAction.Skip:
_settings.SkippedUpdateVersion = _availableUpdate.version;
_settings.SkippedUpdateVersion = _updateSession.Manifest.version;
PersistSettings();
break;
}
_updateDismissedThisRun = true;
_updateOfferPending = false;
_updateModalOpen = false;
_updateSession.Dismiss();
}
private void PersistSettings() => _settingsRepository.Save(_settings);
@@ -5171,7 +5089,7 @@ namespace ProjectQuint.Bootstrap
{
if (keyboard == null)
return false;
if (_profileModalOpen)
if (_profileSession.IsOpen)
{
if (keyboard.iKey.wasPressedThisFrame)
{
@@ -5185,43 +5103,43 @@ namespace ProjectQuint.Bootstrap
return true;
}
if (keyboard.leftShiftKey.wasPressedThisFrame)
_profileModalTab = (_profileModalTab + 3) % 4;
_profileSession.ShiftTab(-1);
if (keyboard.rightShiftKey.wasPressedThisFrame)
_profileModalTab = (_profileModalTab + 1) % 4;
if (_profileModalTab == 1 && _ratingContexts.Count > 1)
_profileSession.ShiftTab(1);
if (_profileSession.Tab == 1 && _profileSession.RatingContexts.Count > 1)
{
if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileRatingContext(-1);
_profileSession.ShiftRatingContext(-1);
if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileRatingContext(1);
_profileSession.ShiftRatingContext(1);
}
if (_profileModalTab == 2)
if (_profileSession.Tab == 2)
{
if (keyboard.leftArrowKey.wasPressedThisFrame)
ShiftProfileCollectionColumn(-1);
_profileSession.ShiftCollectionColumn(-1);
if (keyboard.rightArrowKey.wasPressedThisFrame)
ShiftProfileCollectionColumn(1);
_profileSession.ShiftCollectionColumn(1);
if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileCollectionSelection(-1);
_profileSession.ShiftCollectionSelection(-1);
if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileCollectionSelection(1);
_profileSession.ShiftCollectionSelection(1);
}
if (_profileModalTab == 3 && _profileCourses.BadgeCount > 0)
if (_profileSession.Tab == 3 && _profileSession.Courses.BadgeCount > 0)
{
if (keyboard.upArrowKey.wasPressedThisFrame)
ShiftProfileBadgeSelection(-1);
_profileSession.ShiftBadge(-1);
if (keyboard.downArrowKey.wasPressedThisFrame)
ShiftProfileBadgeSelection(1);
_profileSession.ShiftBadge(1);
if (keyboard.enterKey.wasPressedThisFrame || keyboard.numpadEnterKey.wasPressedThisFrame)
SetFeaturedProfileBadge(_profileBadgeSelection);
SetFeaturedProfileBadge(_profileSession.BadgeSelection);
}
var enterCloses = _profileModalTab != 3 || _profileCourses.BadgeCount == 0;
var enterCloses = _profileSession.Tab != 3 || _profileSession.Courses.BadgeCount == 0;
if (keyboard.escapeKey.wasPressedThisFrame ||
keyboard.f8Key.wasPressedThisFrame ||
keyboard.f11Key.wasPressedThisFrame ||
enterCloses && (keyboard.enterKey.wasPressedThisFrame ||
keyboard.numpadEnterKey.wasPressedThisFrame))
_profileModalOpen = false;
_profileSession.Close();
return true;
}
@@ -5234,9 +5152,9 @@ namespace ProjectQuint.Bootstrap
private void OpenProfileModal(int tab)
{
RefreshProfileSummary();
RefreshProfileRatingThumbnailSongs();
_profileModalTab = Mathf.Clamp(tab, 0, 3);
_profileModalOpen = true;
_profileThumbnails.Refresh(_profileSession.RatingContexts,
_libraryController?.State?.AllSongs, _libraryController?.State?.SongsRevision ?? 0, _packRegistry);
_profileSession.Open(tab);
}
private void HandleProfileModalUi(ProfileModalUiResult profileResult)
@@ -5246,19 +5164,19 @@ namespace ProjectQuint.Bootstrap
if (profileResult.ResetProfileImage)
ResetProfileImage();
if (profileResult.SelectedTab >= 0)
_profileModalTab = profileResult.SelectedTab;
_profileSession.SelectTab(profileResult.SelectedTab);
if (profileResult.RatingContextDelta != 0)
ShiftProfileRatingContext(profileResult.RatingContextDelta);
_profileSession.ShiftRatingContext(profileResult.RatingContextDelta);
if (profileResult.SelectedBadgeIndex >= 0)
_profileBadgeSelection = profileResult.SelectedBadgeIndex;
_profileSession.SelectBadge(profileResult.SelectedBadgeIndex);
if (profileResult.SelectedCollectionColumn >= 0)
SetProfileCollectionSelection(
_profileSession.SelectCollection(
profileResult.SelectedCollectionColumn,
profileResult.SelectedCollectionIndex);
if (!string.IsNullOrWhiteSpace(profileResult.FeaturedBadgeCourseId))
SetFeaturedProfileBadge(_profileBadgeSelection);
SetFeaturedProfileBadge(_profileSession.BadgeSelection);
if (profileResult.Close)
_profileModalOpen = false;
_profileSession.Close();
}
private void LoadProfileImage()
@@ -6193,292 +6111,32 @@ namespace ProjectQuint.Bootstrap
private void RefreshProfileSummary()
{
_profileSnapshot = _progressionRepository?.Snapshot() ?? default;
_profileCollection = SongRecordRepository.Shared.CalculateCollection();
_profileCourses = CourseRecordRepository.Shared.CalculateCollection(
_progressionRepository?.FeaturedCourseBadgeId);
_ratingContexts = BuildProfileRatingContexts(
SongRecordRepository.Shared.CalculateRatingContexts(),
_difficultyTableRegistry?.Tables,
_libraryController?.State?.AllSongs,
_activeDifficultyTable?.Id,
_profileSnapshot,
SongRecordRepository.Shared.CalculateRating);
_homeRatingSummary = _profileSnapshot.LastRatingKeyCount > 0 &&
!string.IsNullOrWhiteSpace(_profileSnapshot.LastRatingProviderId)
? SongRecordRepository.Shared.CalculateRating(
_profileSnapshot.LastRatingProviderId,
_profileSnapshot.LastRatingProviderDisplayName,
_profileSnapshot.LastRatingKeyCount,
_profileSnapshot.LastRatingHasScratch)
: SongRecordRepository.Shared.CalculateMostRecentRatingContext(
_profileSnapshot.LastRatingKeyCount,
_profileSnapshot.LastRatingHasScratch);
_profileRatingContextIndex = FindRatingContextIndex(_homeRatingSummary);
if (_ratingContexts.Count > 0)
_profileRatingContextIndex = 0;
_profileRatingSummary = _ratingContexts.Count > 0
? _ratingContexts[_profileRatingContextIndex]
: _homeRatingSummary;
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
ClampProfileCollectionSelections();
}
private void RefreshProfileRatingThumbnailSongs()
{
var requestedChartIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var context in _ratingContexts ?? Array.Empty<RatingSummary>())
{
foreach (var entry in context.BestPlays ?? Array.Empty<RatingChartEntry>())
{
if (!string.IsNullOrWhiteSpace(entry.ChartId))
requestedChartIds.Add(entry.ChartId);
}
}
var signature = 17L;
unchecked
{
signature = signature * 31 + (_libraryController?.State?.SongsRevision ?? 0);
foreach (var chartId in requestedChartIds.OrderBy(id => id, StringComparer.Ordinal))
signature = signature * 31 + StringComparer.Ordinal.GetHashCode(chartId);
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
var cachePath = _packRegistry.CachePathFor(pack);
var cache = new FileInfo(cachePath);
signature = signature * 31 + StringComparer.OrdinalIgnoreCase.GetHashCode(pack.Id ?? string.Empty);
signature = signature * 31 + (cache.Exists ? cache.Length : 0L);
signature = signature * 31 + (cache.Exists ? cache.LastWriteTimeUtc.Ticks : 0L);
}
}
if (signature == _profileRatingThumbnailSourceSignature)
return;
var songsByChartId = new Dictionary<string, SongLibraryEntry>(StringComparer.Ordinal);
void AddSong(SongLibraryEntry song)
{
if (song == null)
return;
var chartId = ChartIdentityResolver.Resolve(song).Value;
if (requestedChartIds.Contains(chartId) && !songsByChartId.ContainsKey(chartId))
songsByChartId.Add(chartId, song);
}
foreach (var song in _libraryController?.State?.AllSongs ?? Array.Empty<SongLibraryEntry>())
AddSong(song);
foreach (var pack in _packRegistry?.Packs ?? Array.Empty<BmsPackDefinition>())
{
foreach (var song in SongLibraryScanner.LoadCachedThumbnailEntries(
_packRegistry.CachePathFor(pack),
pack.ContentRoot,
pack.Id,
requestedChartIds))
AddSong(song);
}
_profileRatingThumbnailSongs = songsByChartId.Values.ToArray();
_profileRatingThumbnailSourceSignature = signature;
unchecked
{
_profileRatingThumbnailSongsRevision++;
}
}
internal static IReadOnlyList<RatingSummary> BuildProfileRatingContexts(
IReadOnlyList<RatingSummary> recordedContexts,
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
IReadOnlyList<SongLibraryEntry> activeSongs,
string activeTableId,
ProfileProgressSnapshot profile,
Func<string, string, int, bool, RatingSummary> resolveRating)
{
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null || string.IsNullOrWhiteSpace(table.Id))
continue;
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
? table.Id.Trim()
: table.DisplayName.Trim();
}
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
{
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
return;
var normalizedId = providerId.Trim();
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
if (contexts.ContainsKey(key))
return;
var displayName = string.IsNullOrWhiteSpace(providerName)
? tableNames.TryGetValue(normalizedId, out var registeredName)
? registeredName
: normalizedId
: providerName.Trim();
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
}
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
{
if (!recorded.HasContext)
continue;
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
}
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null)
continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch);
}
if (!string.IsNullOrWhiteSpace(activeTableId))
{
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
? registeredName
: activeTableId;
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
.Where(song => song?.Metadata != null &&
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
song.Catalog?.HasDifficulty == true &&
string.Equals(song.Catalog.ProviderId, activeTableId,
StringComparison.OrdinalIgnoreCase))
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
.Distinct())
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
}
AddResolved(
profile.LastRatingProviderId,
profile.LastRatingProviderDisplayName,
profile.LastRatingKeyCount,
profile.LastRatingHasScratch);
return contexts.Values
.OrderByDescending(summary => summary.ActivityPlayCount)
.ThenByDescending(summary => summary.FilledSlots)
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
.ThenBy(summary => summary.KeyCount)
.ThenBy(summary => summary.HasScratch)
.ToArray();
}
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
private int FindRatingContextIndex(RatingSummary target)
{
for (var index = 0; index < _ratingContexts.Count; index++)
{
var context = _ratingContexts[index];
if (string.Equals(context.ProviderId, target.ProviderId, StringComparison.OrdinalIgnoreCase) &&
context.KeyCount == target.KeyCount && context.HasScratch == target.HasScratch)
return index;
}
return 0;
}
private void ShiftProfileRatingContext(int delta)
{
if (_ratingContexts.Count == 0 || delta == 0)
return;
_profileRatingContextIndex = (_profileRatingContextIndex + delta) % _ratingContexts.Count;
if (_profileRatingContextIndex < 0)
_profileRatingContextIndex += _ratingContexts.Count;
_profileRatingSummary = _ratingContexts[_profileRatingContextIndex];
}
private IReadOnlyList<CollectionGroupSummary> ProfileCollectionGroups(int column)
{
return Mathf.Clamp(column, 0, 2) switch
{
0 => _profileCollection.Packs,
1 => _profileCollection.KeyModes,
_ => _profileCollection.Difficulties,
};
}
private void ShiftProfileCollectionColumn(int delta)
{
if (delta == 0)
return;
_profileCollectionColumn = (_profileCollectionColumn + delta) % 3;
if (_profileCollectionColumn < 0)
_profileCollectionColumn += 3;
ClampProfileCollectionSelections();
}
private void ShiftProfileCollectionSelection(int delta)
{
var groups = ProfileCollectionGroups(_profileCollectionColumn);
if (groups == null || groups.Count == 0 || delta == 0)
return;
var current = _profileCollectionSelections[_profileCollectionColumn];
current = (current + delta) % groups.Count;
if (current < 0)
current += groups.Count;
_profileCollectionSelections[_profileCollectionColumn] = current;
}
private void SetProfileCollectionSelection(int column, int index)
{
_profileCollectionColumn = Mathf.Clamp(column, 0, 2);
var groups = ProfileCollectionGroups(_profileCollectionColumn);
_profileCollectionSelections[_profileCollectionColumn] = groups == null || groups.Count == 0
? 0
: Mathf.Clamp(index, 0, groups.Count - 1);
}
private void ClampProfileCollectionSelections()
{
_profileCollectionColumn = Mathf.Clamp(_profileCollectionColumn, 0, 2);
for (var column = 0; column < _profileCollectionSelections.Length; column++)
{
var groups = ProfileCollectionGroups(column);
_profileCollectionSelections[column] = groups == null || groups.Count == 0
? 0
: Mathf.Clamp(_profileCollectionSelections[column], 0, groups.Count - 1);
}
}
private static int FindFeaturedBadgeIndex(ProfileCourseSnapshot courses)
{
if (courses == null)
return 0;
for (var index = 0; index < courses.CertificationBadges.Count; index++)
{
if (string.Equals(
courses.CertificationBadges[index].CourseId,
courses.FeaturedBadgeCourseId,
StringComparison.OrdinalIgnoreCase))
return index;
}
return 0;
}
private void ShiftProfileBadgeSelection(int delta)
{
if (_profileCourses.BadgeCount == 0 || delta == 0)
return;
_profileBadgeSelection = (_profileBadgeSelection + delta) % _profileCourses.BadgeCount;
if (_profileBadgeSelection < 0)
_profileBadgeSelection += _profileCourses.BadgeCount;
var snapshot = _progressionRepository?.Snapshot() ?? default;
var records = SongRecordRepository.Shared;
var contexts = ProfileRatingContextBuilder.Build(
records.CalculateRatingContexts(), _difficultyTableRegistry?.Tables,
_libraryController?.State?.AllSongs, _activeDifficultyTable?.Id,
snapshot, records.CalculateRating);
var fallbackRating = snapshot.LastRatingKeyCount > 0 &&
!string.IsNullOrWhiteSpace(snapshot.LastRatingProviderId)
? records.CalculateRating(snapshot.LastRatingProviderId,
snapshot.LastRatingProviderDisplayName,
snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch)
: records.CalculateMostRecentRatingContext(
snapshot.LastRatingKeyCount, snapshot.LastRatingHasScratch);
_profileSession.Refresh(snapshot, records.CalculateCollection(),
CourseRecordRepository.Shared.CalculateCollection(_progressionRepository?.FeaturedCourseBadgeId),
contexts, fallbackRating);
}
private void SetFeaturedProfileBadge(int badgeIndex)
{
if (_profileCourses.BadgeCount == 0)
_profileSession.SelectBadge(badgeIndex);
var courseId = _profileSession.SelectedBadgeCourseId;
if (string.IsNullOrEmpty(courseId))
return;
var normalized = Mathf.Clamp(badgeIndex, 0, _profileCourses.BadgeCount - 1);
var badge = _profileCourses.CertificationBadges[normalized];
_progressionRepository?.SetFeaturedCourseBadge(badge.CourseId);
_profileCourses = CourseRecordRepository.Shared.CalculateCollection(badge.CourseId);
_profileBadgeSelection = FindFeaturedBadgeIndex(_profileCourses);
_progressionRepository?.SetFeaturedCourseBadge(courseId);
_profileSession.RefreshCourses(CourseRecordRepository.Shared.CalculateCollection(courseId));
}
private void UpdateResultsInput()
@@ -7190,6 +6848,7 @@ namespace ProjectQuint.Bootstrap
private void OnDestroy()
{
_updateSession.Cancel();
CloseAudioSyncCalibration();
FlushPendingLibraryViewState(force: true);
_audio?.Resume();
@@ -8,38 +8,6 @@ using UnityEngine.Networking;
namespace ProjectQuint.Bootstrap
{
[Serializable]
public sealed class ProjectQuintUpdateManifest
{
public int schemaVersion;
public string channel = string.Empty;
public bool available;
public string version = string.Empty;
public int buildNumber;
public string publishedAt = string.Empty;
public string downloadUrl = string.Empty;
public string notesUrl = string.Empty;
public string sha256 = string.Empty;
public long sizeBytes;
}
public readonly struct ProjectQuintUpdateCheckResult
{
public ProjectQuintUpdateCheckResult(
bool succeeded,
ProjectQuintUpdateManifest manifest,
string error)
{
Succeeded = succeeded;
Manifest = manifest;
Error = error ?? string.Empty;
}
public bool Succeeded { get; }
public ProjectQuintUpdateManifest Manifest { get; }
public string Error { get; }
}
public static class ProjectQuintUpdateService
{
public const string ManifestUrl =
@@ -47,7 +15,6 @@ namespace ProjectQuint.Bootstrap
public const string DownloadPageUrl =
"https://projectquint.duckdns.org/download/";
public const string ExpectedHost = "projectquint.duckdns.org";
public static readonly TimeSpan AutomaticCheckInterval = TimeSpan.FromHours(24);
private static readonly Regex AlphaVersionPattern = new Regex(
@"^\d+\.\d+\.\d+-alpha\.\d+$",
@@ -146,31 +113,6 @@ namespace ProjectQuint.Bootstrap
return true;
}
public static bool IsUpdateAvailable(ProjectQuintUpdateManifest manifest) =>
manifest != null &&
manifest.available &&
manifest.buildNumber > ProjectQuintReleaseVersion.BuildNumber;
public static bool ShouldOffer(
ProjectQuintUpdateManifest manifest,
string skippedVersion,
bool manual) =>
IsUpdateAvailable(manifest) &&
(manual ||
!string.Equals(
manifest.version,
skippedVersion?.Trim(),
StringComparison.OrdinalIgnoreCase));
public static bool ShouldCheckAutomatically(long lastSuccessfulCheckUtcTicks, long nowUtcTicks)
{
if (lastSuccessfulCheckUtcTicks <= 0 || nowUtcTicks <= lastSuccessfulCheckUtcTicks)
return true;
return new TimeSpan(nowUtcTicks - lastSuccessfulCheckUtcTicks) >=
AutomaticCheckInterval;
}
private static bool TryValidateNotesUrl(string value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -67,6 +67,21 @@ namespace ProjectQuint.Features.Flow
Mode = mode;
}
public bool TryBeginPlayFromHome(int activePackSelection = 0, int packCount = 0)
{
if (Mode != AppMode.Home)
return false;
if (packCount < 0)
throw new ArgumentOutOfRangeException(nameof(packCount));
PackSelection = Math.Max(0, Math.Min(activePackSelection, packCount + 1));
Status = packCount > 0
? $"{packCount:N0} BMS PACKS REGISTERED"
: "SELECT A FOLDER OR ZIP TO BEGIN";
TransitionTo(AppMode.PackSelect);
return true;
}
public void BeginOptions()
{
RebindLane = -1;
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a699850f7cb543659720ced37887b8b6
folderAsset: yes
@@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ProjectQuint.Core.Progression;
using ProjectQuint.Library;
namespace ProjectQuint.Features.Profile
{
internal static class ProfileRatingContextBuilder
{
public static IReadOnlyList<RatingSummary> Build(
IReadOnlyList<RatingSummary> recordedContexts,
IReadOnlyList<BmsDifficultyTableDefinition> registeredTables,
IReadOnlyList<SongLibraryEntry> activeSongs,
string activeTableId,
ProfileProgressSnapshot profile,
Func<string, string, int, bool, RatingSummary> resolveRating)
{
var contexts = new Dictionary<string, RatingSummary>(StringComparer.OrdinalIgnoreCase);
var tableNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null || string.IsNullOrWhiteSpace(table.Id))
continue;
tableNames[table.Id.Trim()] = string.IsNullOrWhiteSpace(table.DisplayName)
? table.Id.Trim()
: table.DisplayName.Trim();
}
void AddResolved(string providerId, string providerName, int keyCount, bool hasScratch)
{
if (string.IsNullOrWhiteSpace(providerId) || keyCount < 4 || keyCount > 7)
return;
var normalizedId = providerId.Trim();
var key = RatingContextIdentity(normalizedId, keyCount, hasScratch);
if (contexts.ContainsKey(key))
return;
var displayName = string.IsNullOrWhiteSpace(providerName)
? tableNames.TryGetValue(normalizedId, out var registeredName)
? registeredName
: normalizedId
: providerName.Trim();
contexts[key] = resolveRating?.Invoke(normalizedId, displayName, keyCount, hasScratch) ??
new RatingSummary(normalizedId, displayName, keyCount, hasScratch, 0.0, 0);
}
foreach (var recorded in recordedContexts ?? Array.Empty<RatingSummary>())
{
if (!recorded.HasContext)
continue;
contexts[RatingContextIdentity(recorded.ProviderId, recorded.KeyCount, recorded.HasScratch)] = recorded;
}
foreach (var table in registeredTables ?? Array.Empty<BmsDifficultyTableDefinition>())
{
if (table == null)
continue;
var preset = BmsDifficultyTableIndex.FindPreset(table.Id, table.HeaderUrl);
if (preset?.DefaultKeyCount >= 4)
AddResolved(table.Id, table.DisplayName, preset.Value.DefaultKeyCount,
preset.Value.DefaultHasScratch);
}
if (!string.IsNullOrWhiteSpace(activeTableId))
{
var activeName = tableNames.TryGetValue(activeTableId, out var registeredName)
? registeredName
: activeTableId;
foreach (var form in (activeSongs ?? Array.Empty<SongLibraryEntry>())
.Where(song => song?.Metadata != null &&
song.EffectiveKeyCount >= 4 && song.EffectiveKeyCount <= 7 &&
song.Catalog?.HasDifficulty == true &&
string.Equals(song.Catalog.ProviderId, activeTableId,
StringComparison.OrdinalIgnoreCase))
.Select(song => new { KeyCount = song.EffectiveKeyCount, song.Metadata.HasScratch })
.Distinct())
AddResolved(activeTableId, activeName, form.KeyCount, form.HasScratch);
}
AddResolved(
profile.LastRatingProviderId,
profile.LastRatingProviderDisplayName,
profile.LastRatingKeyCount,
profile.LastRatingHasScratch);
return contexts.Values
.OrderByDescending(summary => summary.ActivityPlayCount)
.ThenByDescending(summary => summary.FilledSlots)
.ThenBy(summary => summary.ProviderDisplayName, StringComparer.OrdinalIgnoreCase)
.ThenBy(summary => summary.KeyCount)
.ThenBy(summary => summary.HasScratch)
.ToArray();
}
private static string RatingContextIdentity(string providerId, int keyCount, bool hasScratch) =>
$"{(providerId ?? string.Empty).Trim().ToLowerInvariant()}:{keyCount}:{hasScratch}";
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 65c9c48983064911802938aef1b8ae09
@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using ProjectQuint.Core.Progression;
namespace ProjectQuint.Features.Profile
{
/// <summary>Owns profile navigation independently of the input device, UI and storage.</summary>
internal sealed class ProfileSessionController
{
private readonly int[] _collectionSelections = new int[3];
public ProfileProgressSnapshot Snapshot { get; private set; }
public ProfileCollectionSnapshot Collection { get; private set; } = ProfileCollectionSnapshot.Empty;
public ProfileCourseSnapshot Courses { get; private set; } = ProfileCourseSnapshot.Empty;
public IReadOnlyList<RatingSummary> RatingContexts { get; private set; } = Array.Empty<RatingSummary>();
public RatingSummary RatingSummary { get; private set; }
public int RatingContextIndex { get; private set; }
public int BadgeSelection { get; private set; }
public int CollectionColumn { get; private set; }
public IReadOnlyList<int> CollectionSelections { get; }
public bool IsOpen { get; private set; }
public int Tab { get; private set; }
public string SelectedBadgeCourseId => Courses.BadgeCount == 0
? string.Empty : Courses.CertificationBadges[BadgeSelection].CourseId;
public ProfileSessionController() => CollectionSelections = Array.AsReadOnly(_collectionSelections);
public void Refresh(ProfileProgressSnapshot snapshot, ProfileCollectionSnapshot collection,
ProfileCourseSnapshot courses, IReadOnlyList<RatingSummary> ratingContexts, RatingSummary fallbackRating)
{
Snapshot = snapshot;
Collection = collection ?? ProfileCollectionSnapshot.Empty;
RatingContexts = ratingContexts ?? Array.Empty<RatingSummary>();
// Contexts are already ranked by activity. Opening the profile starts with that ranking.
RatingContextIndex = 0;
RatingSummary = RatingContexts.Count > 0 ? RatingContexts[0] : fallbackRating;
RefreshCourses(courses);
for (var column = 0; column < _collectionSelections.Length; column++)
_collectionSelections[column] = Clamp(_collectionSelections[column], Groups(column).Count - 1);
}
public void RefreshCourses(ProfileCourseSnapshot courses)
{
Courses = courses ?? ProfileCourseSnapshot.Empty;
BadgeSelection = 0;
for (var index = 0; index < Courses.BadgeCount; index++)
if (string.Equals(Courses.CertificationBadges[index].CourseId,
Courses.FeaturedBadgeCourseId, StringComparison.OrdinalIgnoreCase))
{
BadgeSelection = index;
break;
}
}
public void Open(int tab)
{
SelectTab(tab);
IsOpen = true;
}
public void Close() => IsOpen = false;
public void SelectTab(int tab) => Tab = Clamp(tab, 3);
public void ShiftTab(int delta) => Tab = Wrap(Tab, delta, 4);
public void ShiftRatingContext(int delta)
{
if (RatingContexts.Count == 0)
return;
RatingContextIndex = Wrap(RatingContextIndex, delta, RatingContexts.Count);
RatingSummary = RatingContexts[RatingContextIndex];
}
public void ShiftCollectionColumn(int delta) => CollectionColumn = Wrap(CollectionColumn, delta, 3);
public void ShiftCollectionSelection(int delta) =>
_collectionSelections[CollectionColumn] = Wrap(
_collectionSelections[CollectionColumn], delta, Groups(CollectionColumn).Count);
public void SelectCollection(int column, int index)
{
CollectionColumn = Clamp(column, 2);
_collectionSelections[CollectionColumn] = Clamp(index, Groups(CollectionColumn).Count - 1);
}
public void SelectBadge(int index) => BadgeSelection = Clamp(index, Courses.BadgeCount - 1);
public void ShiftBadge(int delta) => BadgeSelection = Wrap(BadgeSelection, delta, Courses.BadgeCount);
private IReadOnlyList<CollectionGroupSummary> Groups(int column) => column switch
{
0 => Collection.Packs,
1 => Collection.KeyModes,
_ => Collection.Difficulties,
};
private static int Clamp(int value, int maximum) => Math.Max(0, Math.Min(value, maximum));
private static int Wrap(int value, int delta, int count) => count <= 0
? 0 : (int)((((long)value + delta) % count + count) % count);
#if UNITY_EDITOR
public void SetPreviewSnapshot(ProfileProgressSnapshot snapshot) => Snapshot = snapshot;
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3d53feec096341b8a67e3bf4404774dd
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fee265429b204c6fbc7524f90b8c438d
folderAsset: yes
@@ -0,0 +1,131 @@
using ProjectQuint.Core.Release;
using ProjectQuint.Features.Flow;
namespace ProjectQuint.Features.Updates
{
/// <summary>
/// Owns one update-check session. A generation prevents an abandoned network request
/// from reopening a dismissed modal or overwriting a newer manual check.
/// Transport, persistence and installer invocation remain at the application boundary.
/// </summary>
internal sealed class UpdateSessionController
{
private int _generation;
private bool _manual;
private bool _automaticCheckStarted;
private bool _offerPending;
public bool IsChecking { get; private set; }
public bool IsModalOpen { get; private set; }
public int Selection { get; private set; }
public string Status { get; private set; } = "NOT CHECKED";
public string Error { get; private set; } = string.Empty;
public ProjectQuintUpdateManifest Manifest { get; private set; }
public long LastSuccessfulCheckUtcTicks { get; private set; }
public bool TryBeginAutomaticCheck(AppMode mode, bool isEditor,
long lastSuccessfulCheckUtcTicks, long nowUtcTicks, out int generation)
{
generation = 0;
if (_automaticCheckStarted || mode != AppMode.Home)
return false;
_automaticCheckStarted = true;
if (isEditor)
return false;
if (!UpdatePolicy.ShouldCheckAutomatically(lastSuccessfulCheckUtcTicks, nowUtcTicks))
{
Status = "CHECKED RECENTLY";
return false;
}
return TryBeginCheck(false, out generation);
}
public bool TryBeginCheck(bool manual, out int generation)
{
generation = _generation;
if (IsChecking)
return false;
generation = ++_generation;
_manual = manual;
IsChecking = true;
IsModalOpen = false;
_offerPending = false;
Manifest = null;
Error = string.Empty;
Status = "CHECKING...";
return true;
}
public bool CompleteCheck(int generation, ProjectQuintUpdateCheckResult result,
string skippedVersion, long nowUtcTicks)
{
if (!IsChecking || generation != _generation)
return false;
IsChecking = false;
if (!result.Succeeded || result.Manifest == null)
{
Error = result.Succeeded ? "UPDATE MANIFEST IS MISSING" : result.Error;
Status = _manual ? "CHECK FAILED" : "NOT CHECKED";
return true;
}
LastSuccessfulCheckUtcTicks = nowUtcTicks;
Manifest = result.Manifest;
Status = UpdatePolicy.IsUpdateAvailable(Manifest)
? $"AVAILABLE // {Manifest.version.ToUpperInvariant()}"
: $"UP TO DATE // {ProjectQuintReleaseVersion.DisplayVersion.ToUpperInvariant()}";
_offerPending = UpdatePolicy.ShouldOffer(Manifest, skippedVersion, _manual);
return true;
}
public bool TryPresent(AppMode mode, bool blocked)
{
if (blocked || IsChecking || !_offerPending || IsModalOpen || Manifest == null ||
(mode != AppMode.Home && mode != AppMode.Options))
return false;
_offerPending = false;
Selection = 0;
IsModalOpen = true;
return true;
}
public void MoveSelection(int delta)
{
if (IsModalOpen)
Selection = ((Selection + delta) % 3 + 3) % 3;
}
public void MarkInstallerOpening() => Status = "OPENING SECURE UPDATE INSTALLER...";
public void Dismiss()
{
IsModalOpen = false;
_offerPending = false;
}
public void AbortCheck(int generation)
{
if (IsChecking && generation == _generation)
Cancel();
}
public void Cancel()
{
++_generation;
if (IsChecking)
Status = "CHECK CANCELLED";
IsChecking = false;
Dismiss();
}
#if UNITY_EDITOR
public void ShowPreview(ProjectQuintUpdateManifest manifest)
{
Cancel();
Manifest = manifest;
Selection = 0;
IsModalOpen = true;
}
#endif
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3f678ce98b05486bac254d968c77eddf
+3
View File
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9a1a356268ef4303b32df7e8fdc9e78f
folderAsset: yes
@@ -0,0 +1,468 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class BmsParsingTests
{
[Test]
public void Parser_DecodesShiftJisJapaneseMetadata()
{
var directory = Path.Combine(Path.GetTempPath(), "ProjectQuint-ShiftJis-" + Guid.NewGuid().ToString("N"));
var path = Path.Combine(directory, "chart.bms");
Directory.CreateDirectory(directory);
try
{
var bytes = new List<byte>();
bytes.AddRange(Encoding.ASCII.GetBytes("#TITLE PUPA\r\n#ARTIST "));
bytes.AddRange(new byte[]
{
0x83, 0x82, 0x83, 0x8A, 0x83, 0x82, 0x83, 0x8A,
0x82, 0xA0, 0x82, 0xC2, 0x82, 0xB5,
});
bytes.AddRange(Encoding.ASCII.GetBytes("\r\n#BPM 202\r\n"));
File.WriteAllBytes(path, bytes.ToArray());
var metadata = BmsParser.ReadMetadataFile(path);
Assert.That(metadata.Title, Is.EqualTo("PUPA"));
Assert.That(metadata.Artist, Is.EqualTo("モリモリあつし"));
}
finally
{
if (Directory.Exists(directory))
Directory.Delete(directory, true);
}
}
[Test]
public void Parser_Base62PreservesCaseSensitiveObjectIdsAcrossAllTimelines()
{
const string source = @"
#TITLE Base62 Test
#BPM 120
#WAVAA upper.wav
#WAVAa lower.wav
#BMPBA upper.png
#BMPBa lower.png
#BPMCA 150
#BPMCa 180
#STOPDA 48
#STOPDa 96
#SCROLLEA 1.25
#SCROLLEa 0.5
#LNOBJ Aa
#00101:AAAa
#00104:BABa
#00108:CACa
#00109:DADa
#001SC:EAEa
#00211:AAAAAa
#RANDOM 1
#IF 1
#BASE 62
#ENDIF
#ENDRANDOM
";
var document = BmsParser.ParseText("base62.bms", source);
var compiled = BmsTimelineCompiler.Compile(document);
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(62));
Assert.That(document.WavDefinitions, Has.Count.EqualTo(2));
Assert.That(document.WavDefinitions["AA"], Is.EqualTo("upper.wav"));
Assert.That(document.WavDefinitions["Aa"], Is.EqualTo("lower.wav"));
Assert.That(document.BmpDefinitions["BA"], Is.EqualTo("upper.png"));
Assert.That(document.BmpDefinitions["Ba"], Is.EqualTo("lower.png"));
Assert.That(document.ExtendedBpms.Keys, Is.EquivalentTo(new[] { "CA", "Ca" }));
Assert.That(document.Stops.Keys, Is.EquivalentTo(new[] { "DA", "Da" }));
Assert.That(document.Scrolls.Keys, Is.EquivalentTo(new[] { "EA", "Ea" }));
Assert.That(document.Metadata.LnObj, Is.EqualTo("Aa"));
Assert.That(chart.AudioResources, Has.Count.EqualTo(2));
Assert.That(chart.AudioResources["AA"], Is.EqualTo("upper.wav"));
Assert.That(chart.AudioResources["Aa"], Is.EqualTo("lower.wav"));
Assert.That(chart.BackgroundAudioEvents.Select(item => item.SoundId),
Is.EqualTo(new[] { "AA", "Aa" }));
Assert.That(chart.VisualEvents.Select(item => item.ResourceId),
Is.EqualTo(new[] { "BA", "Ba" }));
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes.Count(item => item.IsLong), Is.EqualTo(1));
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("Aa"));
Assert.That(chart.Warnings.Any(item => item.StartsWith("Undefined", StringComparison.Ordinal)), Is.False);
}
[Test]
public void Parser_Base36NormalizesLowercaseObjectIdsEvenWhenBaseIsDeclaredLast()
{
const string source = @"
#BPM 120
#WAVAa hit.wav
#BMPbB image.png
#LNOBJ cC
#00111:AaAaCc
#00104:bB
#BASE 36
";
var document = BmsParser.ParseText("base36-lowercase.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Metadata.ObjectBase, Is.EqualTo(36));
Assert.That(document.WavDefinitions.Keys, Is.EqualTo(new[] { "AA" }));
Assert.That(document.BmpDefinitions.Keys, Is.EqualTo(new[] { "BB" }));
Assert.That(document.Metadata.LnObj, Is.EqualTo("CC"));
Assert.That(document.Events.All(item => item.ObjectId == item.ObjectId.ToUpperInvariant()), Is.True);
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes.Single(item => item.IsLong).TailSoundId, Is.EqualTo("CC"));
Assert.That(chart.BgaEvents.Single().ResourceId, Is.EqualTo("BB"));
}
[Test]
public void Parser_RejectsUnknownObjectBaseInsteadOfSilentlyCorruptingIds()
{
const string source = "#BASE 64\n#WAVAA hit.wav\n#00111:AA";
Assert.That(
() => BmsParser.ParseText("unsupported-base.bms", source),
Throws.TypeOf<FormatException>()
.With.Message.Contains("supports only #BASE 36 and #BASE 62"));
}
[Test]
public void Parser_RejectsObsoleteLnTypeTwoInsteadOfMisreadingLongNotes()
{
const string source = "#LNTYPE 2\n#BPM 120\n#00151:0001010100";
Assert.That(
() => BmsParser.ParseText("obsolete-mgq-ln.bms", source),
Throws.TypeOf<FormatException>()
.With.Message.Contains("Unsupported BMS #LNTYPE 2")
.And.Message.Contains("#LNTYPE 1 and #LNOBJ"));
}
[Test]
public void Parser_AcceptsSupportedLnTypeOne()
{
const string source = "#LNTYPE 1\n#BPM 120\n#00151:00010001";
var document = BmsParser.ParseText("standard-ln.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Metadata.LnType, Is.EqualTo(1));
Assert.That(chart.Notes.Count(note => note.IsLong), Is.EqualTo(1));
}
[Test]
public void Converter_PreservesScratchAsFixedLeftLane()
{
const string source = @"
#TITLE Smoke Test
#ARTIST ProjectQuint
#BPM 120
#WAV01 kick.wav
#00111:01
#00112:0001
#00116:01
";
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("smoke.bms", source));
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(chart.Metadata.Title, Is.EqualTo("Smoke Test"));
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(5));
Assert.That(chart.Metadata.HasScratch, Is.True);
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(6));
Assert.That(chart.Notes, Has.Count.EqualTo(3));
Assert.That(chart.Notes[0].Lane, Is.EqualTo(0));
Assert.That(chart.Notes[0].IsScratch, Is.True);
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(1));
Assert.That(chart.Notes, Has.Some.Property("Lane").EqualTo(2));
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
}
[Test]
public void Parser_PreservesDeclaredTotalForGaugeCalculation()
{
const string source = @"
#TITLE Gauge Test
#BPM 120
#TOTAL 470.5
#00111:01
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("total.bms", source));
Assert.That(chart.Metadata.Total, Is.EqualTo(470.5).Within(0.000001));
}
[Test]
public void LaneModifier_MirrorReversesAllFiveLanes()
{
var laneMap = LanePermutation.Create(LaneModifier.Mirror);
Assert.That(laneMap, Is.EqualTo(new[] { 4, 3, 2, 1, 0 }));
}
[Test]
public void LaneModifier_MirrorKeepsScratchFixedAndReversesSevenKeys()
{
var laneMap = LanePermutation.Create(LaneModifier.Mirror, keyCount: 7, hasScratch: true);
Assert.That(laneMap, Is.EqualTo(new[] { 0, 7, 6, 5, 4, 3, 2, 1 }));
}
[Test]
public void ParserAndConverter_SupportOnePlusSevenAsEightGameplayLanes()
{
const string source = @"
#TITLE 1+7 Test
#BPM 120
#WAV01 hit.wav
#00111:01
#00112:01
#00113:01
#00114:01
#00115:01
#00118:01
#00119:01
#00116:01
";
var compiled = BmsTimelineCompiler.Compile(BmsParser.ParseText("one-plus-seven.bms", source));
var chart = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(7));
Assert.That(chart.Metadata.HasScratch, Is.True);
Assert.That(chart.Metadata.TotalLaneCount, Is.EqualTo(8));
Assert.That(chart.Notes, Has.Count.EqualTo(8));
Assert.That(chart.Notes.Select(note => note.Lane), Is.EquivalentTo(Enumerable.Range(0, 8)));
Assert.That(chart.Notes.Single(note => note.IsScratch).Lane, Is.Zero);
}
[TestCase(7, false, "7K")]
[TestCase(7, true, "1+7K")]
[TestCase(5, false, "5K")]
[TestCase(5, true, "1+5K")]
public void KeyModeLabel_DistinguishesKeysFromScratchAndKeys(
int keyCount,
bool hasScratch,
string expected)
{
Assert.That(
ChartKeyMode.FormatLabel(keyCount, hasScratch),
Is.EqualTo(expected));
}
[TestCase("#00111:01\n#00112:01\n#00114:01\n#00115:01", 4)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01", 5)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00115:01\n#00118:01\n#00119:01", 6)]
[TestCase("#00111:01\n#00112:01\n#00113:01\n#00114:01\n#00115:01\n#00118:01\n#00119:01", 7)]
[TestCase("#00151:0102\n#00152:0102\n#00154:0102\n#00155:0102", 4)]
public void Parser_DetectsSupportedKeyLayouts(string events, int expectedKeyCount)
{
var document = BmsParser.ParseText(
"layout.bms",
"#BPM 120\n#WAV01 hit.wav\n#WAV02 tail.wav\n" + events);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(expectedKeyCount));
Assert.That(chart.Notes.Select(note => note.Lane).Distinct().Count(), Is.EqualTo(expectedKeyCount));
}
[Test]
public void SixKeyContract_SkipsCenterChannelAndKeepsRightmostChannel()
{
var document = BmsParser.ParseText(
"six-key-with-center-stray.bms",
"#BPM 120\n" +
"#WAV01 one.wav\n#WAV02 two.wav\n#WAV03 three.wav\n" +
"#WAV04 stray.wav\n#WAV05 five.wav\n#WAV06 six.wav\n#WAV07 seven.wav\n" +
"#00111:01\n#00112:02\n#00113:03\n#00114:04\n" +
"#00115:05\n#00118:06\n#00119:07\n");
document.Metadata.KeyCount = 6;
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(chart.Metadata.KeyCount, Is.EqualTo(6));
Assert.That(chart.Notes, Has.Count.EqualTo(6));
Assert.That(chart.Notes.Select(note => note.Lane),
Is.EquivalentTo(Enumerable.Range(0, 6)));
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Contain("07"));
Assert.That(chart.Notes.Select(note => note.SoundId), Does.Not.Contain("04"));
}
[Test]
public void LaneModifier_RandomCreatesOneDeterministicPermutationPerPlay()
{
var first = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
var second = LanePermutation.Create(LaneModifier.Random, randomSeed: 31415);
Assert.That(first, Is.EqualTo(second));
Assert.That(first, Is.EquivalentTo(new[] { 0, 1, 2, 3, 4 }));
Assert.That(first, Is.Not.EqualTo(new[] { 0, 1, 2, 3, 4 }));
}
[Test]
public void PlayableChartConverter_AppliesTheSameMappingWithoutMutatingSourceNotes()
{
var notes = Enumerable.Range(0, 5)
.Select(lane => new BmsNote { Id = lane, Lane = lane })
.ToArray();
var source = new BmsChart();
source.Notes.AddRange(notes);
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Random, randomSeed: 7);
for (var lane = 0; lane < notes.Length; lane++)
{
Assert.That(conversion.Chart.Notes.Single(note => note.Id == lane).Lane,
Is.EqualTo(conversion.LaneMap[lane]));
Assert.That(notes[lane].Lane, Is.EqualTo(lane));
}
}
[Test]
public void InvisibleBmsChannels_BecomeModifierAwareLaneSoundEvents()
{
var document = BmsParser.ParseText(
"invisible.bms",
"#BPM 120\n#WAV01 visible.wav\n#WAV02 empty.wav\n#00111:01\n#00231:02");
var source = BmsTimelineCompiler.Compile(document);
Assert.That(source.LaneSoundEvents, Has.Count.EqualTo(1));
Assert.That(source.LaneSoundEvents[0].Lane, Is.Zero);
Assert.That(source.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
var conversion = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror);
Assert.That(conversion.Chart.LaneSoundEvents, Has.Count.EqualTo(1));
Assert.That(conversion.Chart.LaneSoundEvents[0].Lane, Is.EqualTo(4));
Assert.That(conversion.Chart.LaneSoundEvents[0].SoundId, Is.EqualTo("02"));
}
[Test]
public void PlayableChartConverter_PreservesLongNoteIdentityTimingAndResourceTimelines()
{
var source = new BmsChart
{
Metadata = new BmsMetadata
{
ChartPath = "/songs/example/chart.bms",
Title = "Example",
Artist = "Composer",
StageFile = "stage.png",
Total = 420,
},
};
source.WavDefinitions["01"] = "head.wav";
source.BmpDefinitions["02"] = "movie.mp4";
source.Notes.Add(new BmsNote
{
Id = 42,
Lane = 1,
StartTime = 1.25,
EndTime = 3.5,
SoundId = "01",
TailSoundId = "09",
});
source.BackgroundSounds.Add(new BmsAudioEvent { Time = 0.5, SoundId = "01" });
source.BgaEvents.Add(new BmsBgaEvent
{
Time = 0.75,
ResourceId = "02",
Layer = BmsBgaLayer.Layer,
});
var chart = BmsPlayableChartConverter.Convert(source, LaneModifier.Mirror).Chart;
Assert.That(chart.Notes, Has.Count.EqualTo(1));
Assert.That(chart.Notes[0].Id, Is.EqualTo(42));
Assert.That(chart.Notes[0].Lane, Is.EqualTo(3));
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(1.25));
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.5));
Assert.That(chart.Notes[0].SoundId, Is.EqualTo("01"));
Assert.That(chart.Notes[0].TailSoundId, Is.EqualTo("09"));
Assert.That(chart.Metadata.SourcePath, Is.EqualTo("/songs/example/chart.bms"));
Assert.That(chart.Metadata.GaugeTotal, Is.EqualTo(420));
Assert.That(chart.AudioResources["01"], Is.EqualTo("head.wav"));
Assert.That(chart.VisualResources["02"], Is.EqualTo("movie.mp4"));
Assert.That(chart.BackgroundAudioEvents, Has.Count.EqualTo(1));
Assert.That(chart.VisualEvents[0].Layer, Is.EqualTo(PlayableVisualLayer.Overlay));
}
[Test]
public void PlayableChartConverter_PreservesScratchSoundAndDuration()
{
var source = new BmsChart
{
Metadata = new BmsMetadata { KeyCount = 5, HasScratch = true },
ScratchObjectCount = 1,
Duration = 102.0,
};
source.Notes.Add(new BmsNote
{
Id = 1,
Lane = 1,
StartTime = 1.0,
EndTime = 1.0,
SoundId = "KEY",
});
source.Notes.Add(new BmsNote
{
Id = 2,
Lane = 0,
StartTime = 100.0,
EndTime = 100.0,
SoundId = "SCRATCH",
IsScratch = true,
});
var chart = BmsPlayableChartConverter.Convert(source).Chart;
Assert.That(chart.Notes, Has.Count.EqualTo(2));
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("KEY"));
Assert.That(chart.Notes, Has.Some.Property("SoundId").EqualTo("SCRATCH"));
Assert.That(chart.ScratchObjectCount, Is.EqualTo(1));
Assert.That(chart.Duration, Is.EqualTo(102.0));
Assert.That(source.Notes, Has.Count.EqualTo(2));
Assert.That(source.Duration, Is.EqualTo(102.0));
}
[Test]
public void ParserAndTimeline_CompileBmsScrollWithoutChangingJudgementTime()
{
const string source = @"
#TITLE Scroll Test
#BPM 120
#SCROLL01 0.5
#SCROLL02 2.0
#001SC:0102
#00111:0101
";
var document = BmsParser.ParseText("scroll.bms", source);
var chart = BmsTimelineCompiler.Compile(document);
Assert.That(document.Scrolls["01"], Is.EqualTo(0.5));
Assert.That(document.Scrolls["02"], Is.EqualTo(2.0));
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes[1].StartTime, Is.EqualTo(3.0).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(4), Is.EqualTo(4).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(6), Is.EqualTo(5).Within(0.000001));
Assert.That(chart.ScrollMap.PositionAtBeat(8), Is.EqualTo(9).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 11e00ecd33ce4a44b4ba5ae35921a2b9
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 551153b8a58bd4ddd8daf9dd5dcde978
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ff984ac8f483b429aace8d868437ea96
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class GaugeTests
{
[Test]
public void PlayGauge_NormalUsesTotalPerNoteAndSeventyFivePercentBorder()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 100, totalNotes: 100);
Assert.That(gauge.Value, Is.EqualTo(20));
Assert.That(gauge.IsCleared, Is.False);
for (var index = 0; index < 55; index++)
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(75).Within(0.000001));
Assert.That(gauge.IsCleared, Is.True);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(69).Within(0.000001));
}
[Test]
public void PlayGauge_CourseModeCarriesValueAndAppliesLr2DamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 29.0);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(27.2).Within(0.000001));
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(26.0).Within(0.000001));
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(26.1).Within(0.000001));
Assert.That(gauge.IsCleared, Is.True);
}
[Test]
public void PlayGauge_FiveKeyCourseProfileUsesConfiguredDeltas()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 50.0,
courseGaugeProfile: CourseGaugeProfile.FiveKey);
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Good);
gauge.Apply(JudgementGrade.Miss);
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(48.51).Within(0.000001));
}
[Test]
public void PlayGauge_SevenKeyCourseProfileUsesThresholdDamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.Class,
declaredTotal: 0,
totalNotes: 100,
restoredValue: 24.0,
courseGaugeProfile: CourseGaugeProfile.SevenKey);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(21.6).Within(0.000001));
gauge.Apply(JudgementGrade.Cool);
Assert.That(gauge.Value, Is.EqualTo(21.72).Within(0.000001));
}
[Test]
public void PlayGauge_NormalNeverDropsBelowTwoPercent()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 300, totalNotes: 100);
for (var index = 0; index < 20; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(2));
Assert.That(gauge.IsFailed, Is.False);
Assert.That(gauge.IsCleared, Is.False);
}
[Test]
public void PlayGauge_HardRecoversAndUsesLowGaugeDamageReduction()
{
var gauge = new PlayGauge(GaugeMode.Hard, declaredTotal: 300, totalNotes: 100);
for (var index = 0; index < 6; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(40));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(32));
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Cool);
gauge.Apply(JudgementGrade.Good);
Assert.That(gauge.Value, Is.EqualTo(32.3).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_StandardHardRecoversAndReducesDamageAtLowGauge()
{
var gauge = new PlayGauge(
GaugeMode.Hard,
declaredTotal: 300,
totalNotes: 100,
restoredValue: 49.0);
gauge.Apply(JudgementGrade.Kool);
Assert.That(gauge.Value, Is.EqualTo(49.15).Within(0.000001));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(41.15).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_StandardHardSurvivesTenConsecutiveMisses()
{
var gauge = new PlayGauge(
GaugeMode.Hard,
declaredTotal: 300,
totalNotes: 100);
for (var index = 0; index < 10; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(14.0).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
}
[Test]
public void PlayGauge_ExHardUsesReducedRecoveryAndNoLowGaugeDamageReduction()
{
var gauge = new PlayGauge(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
restoredValue: 50.0);
gauge.Apply(JudgementGrade.Kool);
gauge.Apply(JudgementGrade.Cool);
gauge.Apply(JudgementGrade.Good);
Assert.That(gauge.Value, Is.EqualTo(50.21).Within(0.000001));
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(34.21).Within(0.000001));
gauge.ApplyEmptyInputPenalty();
Assert.That(gauge.Value, Is.EqualTo(26.21).Within(0.000001));
}
[Test]
public void PlayGauge_ExHardFailsOnSeventhConsecutiveMiss()
{
var gauge = new PlayGauge(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100);
for (var index = 0; index < 6; index++)
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.EqualTo(4.0).Within(0.000001));
Assert.That(gauge.IsFailed, Is.False);
gauge.Apply(JudgementGrade.Miss);
Assert.That(gauge.Value, Is.Zero);
Assert.That(gauge.IsFailed, Is.True);
}
[Test]
public void GaugeFallbackChain_UsesExactParallelHardAndNormalStates()
{
var chain = new GaugeFallbackChain(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: true);
var hardReference = new PlayGauge(GaugeMode.Hard, 300, 100);
var normalReference = new PlayGauge(GaugeMode.Normal, 300, 100);
GaugeTransition transition = default;
for (var index = 0; index < 7; index++)
{
transition = chain.Apply(JudgementGrade.Miss);
hardReference.Apply(JudgementGrade.Miss);
normalReference.Apply(JudgementGrade.Miss);
}
Assert.That(transition.Changed, Is.True);
Assert.That(transition.From, Is.EqualTo(GaugeMode.ExHard));
Assert.That(transition.To, Is.EqualTo(GaugeMode.Hard));
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Hard));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(hardReference.Value).Within(0.000001));
while (chain.ActiveGauge.Mode == GaugeMode.Hard)
{
transition = chain.Apply(JudgementGrade.Miss);
hardReference.Apply(JudgementGrade.Miss);
normalReference.Apply(JudgementGrade.Miss);
}
Assert.That(transition.Changed, Is.True);
Assert.That(transition.From, Is.EqualTo(GaugeMode.Hard));
Assert.That(transition.To, Is.EqualTo(GaugeMode.Normal));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
for (var index = 0; index < 25; index++)
{
chain.Apply(JudgementGrade.Kool);
normalReference.Apply(JudgementGrade.Kool);
}
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.Normal));
Assert.That(chain.ActiveGauge.Value, Is.EqualTo(normalReference.Value).Within(0.000001));
Assert.That(chain.ActiveGauge.IsCleared, Is.True);
}
[TestCase(GaugeMode.Class)]
[TestCase(GaugeMode.Normal)]
public void GaugeFallbackChain_DoesNotCreateUnsupportedFallbacks(GaugeMode mode)
{
var chain = new GaugeFallbackChain(
mode,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: true);
Assert.That(chain.GaugeCount, Is.EqualTo(1));
Assert.That(chain.HasFallback, Is.False);
}
[Test]
public void GaugeFallbackChain_DisabledExHardStillFailsWithoutTransition()
{
var chain = new GaugeFallbackChain(
GaugeMode.ExHard,
declaredTotal: 300,
totalNotes: 100,
fallbackEnabled: false);
GaugeTransition transition = default;
for (var index = 0; index < 7; index++)
transition = chain.Apply(JudgementGrade.Miss);
Assert.That(transition.Changed, Is.False);
Assert.That(chain.ActiveGauge.Mode, Is.EqualTo(GaugeMode.ExHard));
Assert.That(chain.ActiveGauge.IsFailed, Is.True);
}
[Test]
public void PlayGauge_UsesOfficialDefaultTotalWhenHeaderIsMissing()
{
var gauge = new PlayGauge(GaugeMode.Normal, declaredTotal: 0, totalNotes: 1000);
Assert.That(gauge.Total, Is.EqualTo(7.605 * 1000 / 16.5).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 443a38441d2c477db7e4983a4b938f83
@@ -0,0 +1,263 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class MusicalTimingTests
{
[Test]
public void Compiler_PreservesBmpDefinitionsAndBgaLayerEvents()
{
const string source = @"
#BPM 120
#BMP01 movie.mpg
#BMP02 poor.png
#BMP03 layer.png
#00104:01
#00106:02
#00107:03
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("bga.bms", source));
Assert.That(chart.BmpDefinitions["01"], Is.EqualTo("movie.mpg"));
Assert.That(chart.BgaEvents, Has.Count.EqualTo(3));
Assert.That(chart.BgaEvents[0].Time, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Base));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Poor));
Assert.That(chart.BgaEvents, Has.Some.Property("Layer").EqualTo(BmsBgaLayer.Layer));
}
[Test]
public void Compiler_PairsLongNoteChannelWithoutTailJudgement()
{
const string source = @"
#BPM 120
#WAV01 head.wav
#WAV02 tail.wav
#00151:0102
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("long.bms", source));
Assert.That(chart.Notes, Has.Count.EqualTo(1));
Assert.That(chart.Notes[0].IsLong, Is.True);
Assert.That(chart.Notes[0].StartTime, Is.EqualTo(2.0).Within(0.000001));
Assert.That(chart.Notes[0].EndTime, Is.EqualTo(3.0).Within(0.000001));
}
[TestCase(120.0, 0.125)]
[TestCase(150.0, 0.100)]
[TestCase(180.0, 0.0833333333333333)]
public void LongNoteMusicalSixteenths_FollowTempo(double bpm, double expectedInterval)
{
var tempoMap = TempoMap.Constant(bpm);
var note = new PlayableNote
{
Id = 1,
Lane = 0,
StartTime = 0,
EndTime = tempoMap.BeatToTime(1.0),
StartBeat = 0,
EndBeat = 1.0,
};
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
Assert.That(ticks, Has.Length.EqualTo(4));
Assert.That(ticks[0], Is.EqualTo(expectedInterval).Within(0.000001));
for (var index = 1; index < ticks.Length; index++)
Assert.That(ticks[index] - ticks[index - 1],
Is.EqualTo(expectedInterval).Within(0.000001));
}
[Test]
public void Compiler_PreservesMeasureLengthTempoPriorityAndStopInTempoMap()
{
const string source = @"
#BPM 120
#BPM01 180
#STOP01 48
#00002:0.5
#00003:0078
#00008:0001
#00009:0001
#00011:0001
#00112:01
";
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText("timing.bms", source));
var stopNote = chart.Notes.Single(note => note.Lane == 0);
var nextMeasureNote = chart.Notes.Single(note => note.Lane == 1);
Assert.That(stopNote.StartBeat, Is.EqualTo(1.0).Within(0.000001));
Assert.That(stopNote.StartTime, Is.EqualTo(0.5).Within(0.000001),
"Objects at a STOP position are judged before the STOP duration.");
Assert.That(nextMeasureNote.StartBeat, Is.EqualTo(2.0).Within(0.000001));
Assert.That(nextMeasureNote.StartTime, Is.EqualTo(7.0 / 6.0).Within(0.000001));
Assert.That(chart.TempoMap.Nodes.Count, Is.EqualTo(2));
Assert.That(chart.TempoMap.Nodes[1].Bpm, Is.EqualTo(180));
Assert.That(chart.TempoMap.Nodes[1].StopDuration, Is.EqualTo(1.0 / 3.0).Within(0.000001));
}
[Test]
public void Compiler_DerivesBpmRangeFromUsedTempoEventsOnly()
{
const string source = @"
#BPM 150
#BPM01 210
#BPM02 999
#SCROLL01 0.5
#00103:5A
#00208:01
#002SC:01
#00311:01
";
var compiled = BmsTimelineCompiler.Compile(
BmsParser.ParseText("tempo-range.bms", source));
var playable = BmsPlayableChartConverter.Convert(compiled).Chart;
Assert.That(compiled.Metadata.InitialBpm, Is.EqualTo(150));
Assert.That(compiled.Metadata.MinimumBpm, Is.EqualTo(90));
Assert.That(compiled.Metadata.MaximumBpm, Is.EqualTo(210));
Assert.That(playable.Metadata.InitialTempo, Is.EqualTo(150));
Assert.That(playable.Metadata.MinimumTempo, Is.EqualTo(90));
Assert.That(playable.Metadata.MaximumTempo, Is.EqualTo(210));
Assert.That(
compiled.Metadata.MaximumBpm,
Is.Not.EqualTo(999),
"An unused extended BPM definition must not enter the displayed range.");
}
[Test]
public void ScrollMap_HoldsPositionForStopWhileSongTimeAdvances()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120),
new TempoMapNode(1, 0.5, 180, stopDuration: 1.0 / 3.0),
});
var scrollMap = new ScrollMap(tempoMap);
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(0.7), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(5.0 / 6.0), Is.EqualTo(1.0).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(1.0), Is.EqualTo(1.5).Within(0.000001));
Assert.That(tempoMap.IsStopped(0.7), Is.True);
Assert.That(tempoMap.IsStopped(1.0), Is.False);
}
[Test]
public void ScrollMap_TraversesZeroScrollBeforeFindingForwardVisibilityEnd()
{
var tempoMap = TempoMap.Constant(120);
var scrollMap = new ScrollMap(tempoMap, new[]
{
new ScrollMapNode(1, 0),
new ScrollMapNode(3, 1),
});
var found = scrollMap.TryGetTimeAtForwardDistance(
songTime: 0.25,
distance: 1.0,
out var visibilityEndTime);
Assert.That(found, Is.True);
Assert.That(visibilityEndTime, Is.EqualTo(1.75).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(0.5), Is.EqualTo(1).Within(0.000001));
Assert.That(scrollMap.PositionAtTime(1.5), Is.EqualTo(1).Within(0.000001));
}
[Test]
public void Timeline_WarnsWhenScrollObjectIsUndefined()
{
var chart = BmsTimelineCompiler.Compile(BmsParser.ParseText(
"undefined-scroll.bms",
"#BPM 120\n#001SC:01\n#00111:01"));
Assert.That(chart.Warnings, Has.Some.Contains("Undefined SCROLL object 01"));
Assert.That(chart.ScrollMap.HasVariableScroll, Is.False);
}
[Test]
public void TempoMap_NegativePrerollIsNotShiftedByAStopAtBeatZero()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120, stopDuration: 0.5),
});
Assert.That(tempoMap.TimeToBeat(-0.25), Is.EqualTo(-0.5).Within(0.000001));
Assert.That(tempoMap.TimeToBeat(0.25), Is.Zero.Within(0.000001));
Assert.That(tempoMap.TimeToBeat(0.75), Is.EqualTo(0.5).Within(0.000001));
}
[Test]
public void LongNoteMusicalTicks_CrossTempoChangeWithoutDuplication()
{
var tempoMap = new TempoMap(new[]
{
new TempoMapNode(0, 0, 120),
new TempoMapNode(1, 0.5, 180),
});
var note = new PlayableNote
{
Id = 1,
Lane = 0,
StartTime = 0,
EndTime = 5.0 / 6.0,
StartBeat = 0,
EndBeat = 2,
};
var ticks = LongNoteTickPolicy.MusicalSixteenths.CreateTickTimes(note, tempoMap);
var engine = new RhythmJudgementEngine(new[] { note }, tempoMap: tempoMap);
engine.Press(0, 0);
engine.Update(note.EndTime);
Assert.That(ticks, Has.Length.EqualTo(8));
Assert.That(ticks[3], Is.EqualTo(0.5).Within(0.000001));
Assert.That(ticks[4] - ticks[3], Is.EqualTo(1.0 / 12.0).Within(0.000001));
Assert.That(engine.TotalScoringUnitCount, Is.EqualTo(9));
Assert.That(engine.Combo, Is.EqualTo(9));
Assert.That(engine.Score, Is.EqualTo(1_000_009));
}
[Test]
public void SongClock_UsesDspNormallyAndFallsBackOnlyAfterARealStall()
{
var clock = new ResilientSongClock();
clock.Reset(currentDspTime: 100.0, startDspTime: 100.0, currentRealtime: 0.0);
Assert.That(clock.Sample(100.1, 100.0, 0.1), Is.EqualTo(0.1).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.False);
Assert.That(clock.Sample(100.1, 100.0, 0.2), Is.EqualTo(0.1).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.False);
Assert.That(clock.Sample(100.1, 100.0, 0.4), Is.EqualTo(0.4).Within(0.000001));
Assert.That(clock.IsFallbackActive, Is.True);
Assert.That(clock.Sample(100.1, 100.0, 0.5), Is.EqualTo(0.5).Within(0.000001));
}
[Test]
public void SongClock_MapsInputEventRelativeToTheCurrentInputClock()
{
var mapped = ResilientSongClock.ResolveInputEventSongTime(
inputEventTime: 10.995,
currentInputTime: 11.000,
currentSongTime: 2.000);
Assert.That(mapped, Is.EqualTo(1.995).Within(0.000001));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 32a7895b9d504aa8bdbe439bebff65c7
@@ -0,0 +1,8 @@
{
"name": "ProjectQuint.Tests.Core",
"references": ["ProjectQuint.Core"],
"includePlatforms": ["Editor"],
"optionalUnityReferences": ["TestAssemblies"],
"autoReferenced": false,
"noEngineReferences": true
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 95d4580a877742e9ad20db382f8d33d3
@@ -0,0 +1,497 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using NUnit.Framework;
using ProjectQuint.Core.Bms;
using ProjectQuint.Core.Charts;
using ProjectQuint.Core.Course;
using ProjectQuint.Core.Gauge;
using ProjectQuint.Core.Judgement;
using ProjectQuint.Core.Timing;
namespace ProjectQuint.Tests
{
public sealed class RhythmJudgementTests
{
[Test]
public void LongNoteReleasePolicy_ExposesEightyThreeAndOneHundredMillisecondBoundaries()
{
var strictCandidate = new LongNoteReleasePolicy(0.083333);
var djmaxCandidate = LongNoteReleasePolicy.DjmaxLike;
Assert.That(strictCandidate.Accepts(1.916667, 2.0), Is.True);
Assert.That(strictCandidate.Accepts(1.916665, 2.0), Is.False);
Assert.That(djmaxCandidate.Accepts(1.900000, 2.0), Is.True);
Assert.That(djmaxCandidate.Accepts(1.899998, 2.0), Is.False);
}
[Test]
public void Judgement_KoolBoundaryIsInclusive()
{
var profile = JudgementProfile.ProjectQuint;
Assert.That(profile.Grade(0.042), Is.EqualTo(JudgementGrade.Kool));
Assert.That(profile.Grade(0.042001), Is.EqualTo(JudgementGrade.Cool));
Assert.That(profile.Grade(0.083), Is.EqualTo(JudgementGrade.Cool));
Assert.That(profile.Grade(0.083001), Is.EqualTo(JudgementGrade.Good));
Assert.That(profile.Grade(0.125), Is.EqualTo(JudgementGrade.Good));
Assert.That(profile.Grade(0.125001), Is.EqualTo(JudgementGrade.Miss));
}
[Test]
public void JudgementBreakdown_CountsEveryFinalGradeExactlyOnce()
{
var notes = Enumerable.Range(1, 4)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 1.0);
engine.Press(0, 2.05);
engine.Press(0, 3.10);
engine.Update(4.2);
Assert.That(engine.KoolCount, Is.EqualTo(1));
Assert.That(engine.CoolCount, Is.EqualTo(1));
Assert.That(engine.GoodCount, Is.EqualTo(1));
Assert.That(engine.MissCount, Is.EqualTo(1));
Assert.That(
engine.KoolCount + engine.CoolCount + engine.GoodCount + engine.MissCount,
Is.EqualTo(engine.JudgedNoteCount));
Assert.That(engine.AccuracyPercent, Is.EqualTo(47.5).Within(0.000001));
Assert.That(engine.Score, Is.EqualTo(475001));
Assert.That(engine.Combo, Is.Zero);
}
[Test]
public void LongNote_EarlyReleaseBreaksComboButRemainsVisibleUntilTail()
{
var note = new PlayableNote { Id = 7, Lane = 2, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
var head = engine.Press(2, 1.0);
var release = engine.Release(2, 1.5);
var retry = engine.Press(2, 1.6);
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(release?.Grade, Is.EqualTo(JudgementGrade.Miss));
Assert.That(release?.LongNoteBreak, Is.True);
Assert.That(retry, Is.Null);
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.BrokenHolding));
Assert.That(engine.Combo, Is.Zero);
Assert.That(engine.Score, Is.EqualTo(545461));
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
Assert.That(engine.ResultSequence, Is.EqualTo(7),
"The head, five earned ticks, and long-note break must retrigger HUD feedback.");
Assert.That(engine.AccuracyPercent, Is.EqualTo(600.0 / 11.0).Within(0.000001));
Assert.That(engine.KoolCount, Is.EqualTo(6));
Assert.That(engine.MissCount, Is.EqualTo(5));
Assert.That(engine.PurePerfectCount, Is.EqualTo(6));
Assert.That(engine.IsFinished, Is.False);
engine.Update(2.0);
Assert.That(engine.GetState(7), Is.EqualTo(RuntimeNoteState.Failed));
Assert.That(engine.IsFinished, Is.True);
}
[Test]
public void LongNote_MissedHeadRemainsUnfinishedUntilItsTail()
{
var note = new PlayableNote { Id = 8, Lane = 1, StartTime = 1.0, EndTime = 3.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Update(1.2);
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.BrokenHolding),
"A missed long-note head must enter the dead-until-tail state, not the terminal failed state.");
Assert.That(engine.MissCount, Is.GreaterThan(0));
Assert.That(engine.IsFinished, Is.False,
"A head-missed long note must remain in the gameplay lifecycle until its tail.");
engine.Update(2.999);
Assert.That(engine.IsFinished, Is.False);
engine.Update(3.0);
Assert.That(engine.GetState(8), Is.EqualTo(RuntimeNoteState.Failed));
Assert.That(engine.IsFinished, Is.True);
}
[Test]
public void Accuracy_UsesApprovedGradeWeightsAndOnlyJudgedNotes()
{
var notes = Enumerable.Range(0, 119)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
SoundId = "01",
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
for (var index = 0; index < 118; index++)
engine.Press(0, index);
engine.Press(0, 118.05);
Assert.That(engine.JudgedNoteCount, Is.EqualTo(119));
Assert.That(engine.AccuracyPercent, Is.EqualTo(99.7478991597).Within(0.0000001));
}
[Test]
public void Score_NormalizesAccuracyToOneMillionAndAddsPurePerfectBonus()
{
var notes = Enumerable.Range(0, 16)
.Select(index => new PlayableNote
{
Id = index,
Lane = 0,
StartTime = index,
EndTime = index,
SoundId = "01",
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
for (var index = 0; index < 15; index++)
engine.Press(0, index);
engine.Press(0, 15.05);
Assert.That(engine.AccuracyPercent, Is.EqualTo(98.125).Within(0.000001));
Assert.That(engine.PurePerfectCount, Is.EqualTo(15));
Assert.That(engine.Score, Is.EqualTo(981265));
}
[Test]
public void CourseScore_SumsPerStageScoresAndKeepsAccuracyAcrossStageTransition()
{
var first = new RhythmJudgementEngine(new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.0 },
});
first.Press(0, 1.0);
first.Press(0, 2.05);
var progress = new CourseProgress();
progress.AddStage(first, cleared: true);
var second = new RhythmJudgementEngine(new[]
{
new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0 },
new PlayableNote { Id = 4, Lane = 0, StartTime = 2.0 },
});
Assert.That(progress.Score, Is.EqualTo(first.Score));
Assert.That(progress.StageResults.Count, Is.EqualTo(1));
Assert.That(
progress.CombinedAccuracyPercent(second),
Is.EqualTo(first.AccuracyPercent).Within(0.000001),
"An unjudged next stage must not make the cumulative HUD rate jump to 100%.");
Assert.That(progress.CombinedScore(second), Is.EqualTo(first.Score));
second.Press(0, 1.0);
second.Press(0, 2.0);
progress.AddStage(second, cleared: true);
Assert.That(progress.Score, Is.EqualTo(first.Score + second.Score));
Assert.That(progress.StageResults.Count, Is.EqualTo(2));
Assert.That(progress.StageResults[1].Score, Is.EqualTo(second.Score));
}
[Test]
public void PurePerfectTheoreticalScore_AddsOnePointPerNeutralTimedNote()
{
const int noteCount = 3200;
var notes = Enumerable.Range(0, noteCount)
.Select(index => new PlayableNote
{
Id = index,
Lane = index % 5,
StartTime = index * 0.01,
EndTime = index * 0.01,
})
.ToArray();
var engine = new RhythmJudgementEngine(notes);
foreach (var note in notes)
engine.Press(note.Lane, note.StartTime);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100.0));
Assert.That(engine.PurePerfectCount, Is.EqualTo(noteCount));
Assert.That(engine.Score, Is.EqualTo(1_003_200));
}
[Test]
public void TimingStats_CountSuccessfulPressesButExcludeAutomaticMisses()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0, SoundId = "01" },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0, SoundId = "01" },
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0, SoundId = "01" },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 0.98);
engine.Press(1, 2.03);
engine.Press(2, 3.0);
engine.Update(4.2);
Assert.That(engine.FastCount, Is.Zero);
Assert.That(engine.LateCount, Is.EqualTo(1));
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(1));
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.EqualTo(0.03).Within(0.000001));
Assert.That(engine.AverageTimingOffsetMilliseconds, Is.EqualTo(10.0 / 3.0).Within(0.000001));
Assert.That(engine.MissCount, Is.EqualTo(1));
}
[Test]
public void TimingFeedback_TreatsPlusOrMinusTwentyTwoMillisecondsAsNeutral()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 3.0 },
new PlayableNote { Id = 4, Lane = 3, StartTime = 4.0, EndTime = 4.0 },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 0.978);
engine.Press(1, 2.022);
engine.Press(2, 2.977999);
engine.Press(3, 4.022001);
Assert.That(engine.FastCount, Is.EqualTo(1));
Assert.That(engine.LateCount, Is.EqualTo(1));
Assert.That(engine.RecentTimingSamples, Has.Count.EqualTo(2));
Assert.That(engine.RecentTimingSamples[0].OffsetSeconds, Is.LessThan(-0.022));
Assert.That(engine.RecentTimingSamples[1].OffsetSeconds, Is.GreaterThan(0.022));
Assert.That(engine.TimingHistogram.Sum(), Is.EqualTo(4));
Assert.That(engine.PurePerfectCount, Is.EqualTo(2));
}
[Test]
public void JudgementOffset_ShiftsHitTimingButKeepsPhysicalFeedbackTime()
{
var note = new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
var result = engine.Press(0, 0.98, 0.02);
Assert.That(result?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(result?.OffsetSeconds, Is.Zero.Within(0.000001));
Assert.That(engine.LastResultTime, Is.EqualTo(0.98).Within(0.000001));
Assert.That(engine.FastCount, Is.Zero);
Assert.That(engine.LateCount, Is.Zero);
}
[Test]
public void LongNote_HeldPastTailCompletesWithoutReleaseResult()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0, SoundId = "01" };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
engine.Update(2.1);
var release = engine.Release(0, 2.2);
Assert.That(release, Is.Null);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.MissCount, Is.Zero);
}
[Test]
public void LongNote_ReleaseWithinDjmaxLikeOneHundredMillisecondsOfTailCompletes()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
var release = engine.Release(0, 2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds);
Assert.That(release, Is.Null);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.Combo, Is.EqualTo(11));
Assert.That(engine.MissCount, Is.Zero);
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
}
[Test]
public void LongNote_ReleaseBeyondDjmaxLikeGraceStillFailsPermanently()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.0);
var release = engine.Release(
0,
2.0 - RhythmJudgementEngine.LongNoteReleaseGraceSeconds - 0.000002);
Assert.That(release?.LongNoteBreak, Is.True);
Assert.That(engine.GetState(3), Is.EqualTo(RuntimeNoteState.BrokenHolding));
Assert.That(engine.Combo, Is.Zero);
Assert.That(engine.MissCount, Is.EqualTo(2),
"The 900 ms and tail ticks remain unearned just beyond the 100 ms grace.");
}
[Test]
public void LongNote_HoldTicksIncreaseComboEveryOneHundredMilliseconds()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.05 };
var engine = new RhythmJudgementEngine(new[] { note });
var judgementResults = new List<JudgementResult>();
engine.Judged += judgementResults.Add;
engine.Press(0, 1.0);
Assert.That(engine.Score, Is.EqualTo(90910));
engine.Update(1.099);
Assert.That(engine.Combo, Is.EqualTo(1));
engine.Update(1.1);
Assert.That(engine.Combo, Is.EqualTo(2));
Assert.That(engine.Score, Is.EqualTo(181820));
engine.Update(1.49);
Assert.That(engine.Combo, Is.EqualTo(5));
engine.Update(2.05);
Assert.That(engine.Combo, Is.EqualTo(11));
Assert.That(engine.MaxCombo, Is.EqualTo(11));
Assert.That(engine.Score, Is.EqualTo(1_000_011));
Assert.That(engine.JudgedNoteCount, Is.EqualTo(11));
Assert.That(engine.KoolCount, Is.EqualTo(11));
Assert.That(engine.PurePerfectCount, Is.EqualTo(11));
Assert.That(engine.AccuracyPercent, Is.EqualTo(100));
Assert.That(judgementResults, Has.Count.EqualTo(11));
Assert.That(judgementResults[0].IsHoldTick, Is.False);
Assert.That(judgementResults.Skip(1).All(result =>
result.IsHoldTick && result.Grade == JudgementGrade.Kool), Is.True,
"Every tick after a COOL-or-better head must pop a KOOL judgement.");
}
[Test]
public void LongNote_HoldTicksRaiseAccuracyAndScoreAfterAnImperfectHead()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
engine.Press(0, 1.05);
var headScore = engine.Score;
Assert.That(engine.AccuracyPercent, Is.EqualTo(70.0));
engine.Update(1.1);
Assert.That(engine.AccuracyPercent, Is.EqualTo(85.0));
Assert.That(engine.Score, Is.GreaterThan(headScore));
Assert.That(engine.PurePerfectCount, Is.EqualTo(1),
"A COOL head promotes each successful long-note tick to KOOL Pure Perfect.");
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Kool));
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
}
[Test]
public void LongNote_GoodHeadKeepsEverySuccessfulTickGood()
{
var note = new PlayableNote { Id = 3, Lane = 0, StartTime = 1.0, EndTime = 2.0 };
var engine = new RhythmJudgementEngine(new[] { note });
var head = engine.Press(0, 1.1);
engine.Update(1.1);
Assert.That(head?.Grade, Is.EqualTo(JudgementGrade.Good));
Assert.That(engine.LastResult?.Grade, Is.EqualTo(JudgementGrade.Good));
Assert.That(engine.LastResult?.IsHoldTick, Is.True);
Assert.That(engine.GoodCount, Is.EqualTo(2));
Assert.That(engine.KoolCount, Is.Zero);
Assert.That(engine.PurePerfectCount, Is.Zero);
}
[Test]
public void Judgement_SelectsNearestPendingNoteWithinTheLaneWindow()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 1.1, EndTime = 1.1 },
};
var engine = new RhythmJudgementEngine(notes);
var result = engine.Press(0, 1.08);
Assert.That(result?.NoteId, Is.EqualTo(2));
Assert.That(result?.OffsetSeconds, Is.EqualTo(-0.02).Within(0.000001));
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Pending));
}
[Test]
public void Judgement_ResultDoesNotDependOnUpdateCadence()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 1.0 },
new PlayableNote { Id = 2, Lane = 1, StartTime = 2.0, EndTime = 2.0 },
new PlayableNote { Id = 3, Lane = 2, StartTime = 3.0, EndTime = 4.0 },
};
var frequentUpdates = new RhythmJudgementEngine(notes);
var sparseUpdates = new RhythmJudgementEngine(notes);
for (var time = 0.0; time < 1.8; time += 0.01)
frequentUpdates.Update(time);
frequentUpdates.Press(1, 2.0);
for (var time = 2.01; time < 2.9; time += 0.01)
frequentUpdates.Update(time);
frequentUpdates.Press(2, 3.0);
frequentUpdates.Release(2, 3.5);
for (var time = 3.51; time <= 4.5; time += 0.01)
frequentUpdates.Update(time);
sparseUpdates.Update(1.8);
sparseUpdates.Press(1, 2.0);
sparseUpdates.Update(2.9);
sparseUpdates.Press(2, 3.0);
sparseUpdates.Release(2, 3.5);
sparseUpdates.Update(4.5);
Assert.That(sparseUpdates.Score, Is.EqualTo(frequentUpdates.Score));
Assert.That(sparseUpdates.MissCount, Is.EqualTo(frequentUpdates.MissCount));
Assert.That(sparseUpdates.Combo, Is.EqualTo(frequentUpdates.Combo));
Assert.That(sparseUpdates.AccuracyPercent, Is.EqualTo(frequentUpdates.AccuracyPercent));
Assert.That(sparseUpdates.GetState(1), Is.EqualTo(frequentUpdates.GetState(1)));
Assert.That(sparseUpdates.GetState(2), Is.EqualTo(frequentUpdates.GetState(2)));
Assert.That(sparseUpdates.GetState(3), Is.EqualTo(frequentUpdates.GetState(3)));
Assert.That(sparseUpdates.IsFinished, Is.True);
}
[Test]
public void LongNote_NextHeadCanBeJudgedAfterPreviousTailWithoutFrameUpdate()
{
var notes = new[]
{
new PlayableNote { Id = 1, Lane = 0, StartTime = 1.0, EndTime = 2.0 },
new PlayableNote { Id = 2, Lane = 0, StartTime = 2.2, EndTime = 3.0 },
};
var engine = new RhythmJudgementEngine(notes);
engine.Press(0, 1.0);
var secondHead = engine.Press(0, 2.2);
Assert.That(secondHead?.NoteId, Is.EqualTo(2));
Assert.That(engine.GetState(1), Is.EqualTo(RuntimeNoteState.Completed));
Assert.That(engine.GetState(2), Is.EqualTo(RuntimeNoteState.Holding));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a0d49a1c82734afea812ffafb41ce7b6
@@ -105,26 +105,6 @@ namespace ProjectQuint.Tests
Assert.That(app, Does.Not.Contain("DrawHome("));
}
[Test]
public void HomeActions_ReuseExistingTransitionQuitAndProfilePaths()
{
var app = Read("Unity/Bootstrap/ProjectQuintApp.cs");
var actionStart = app.IndexOf(
"private void ActivateHomeAction(HomeMenuAction action)",
System.StringComparison.Ordinal);
var actionEnd = app.IndexOf(
"private void HandleHomePresenterAction",
actionStart,
System.StringComparison.Ordinal);
var actionBody = app.Substring(actionStart, actionEnd - actionStart);
Assert.That(actionBody, Does.Contain("case HomeMenuAction.Play:"));
Assert.That(actionBody, Does.Contain("_mode = AppMode.PackSelect;"));
Assert.That(actionBody, Does.Contain("OpenOptions(AppMode.Home);"));
Assert.That(actionBody, Does.Contain("Application.Quit();"));
Assert.That(actionBody, Does.Contain("OpenProfileModal(0);"));
}
[Test]
public void MinimalHomeKeyboardAndMouseRoutesPlayAndEscapeQuit()
{
File diff suppressed because it is too large Load Diff
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 6613cb9e0883448468b1bfa7e788894e
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 551153b8a58bd4ddd8daf9dd5dcde978
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: ff984ac8f483b429aace8d868437ea96
@@ -0,0 +1,41 @@
using NUnit.Framework;
using ProjectQuint.Features.Flow;
namespace ProjectQuint.Tests
{
internal sealed class HomeNavigationTests
{
[Test]
public void PlayFromHome_TransitionsToPackSelection()
{
var flow = new AppFlowController();
flow.TransitionTo(AppMode.Home);
Assert.That(flow.TryBeginPlayFromHome(), Is.True);
Assert.That(flow.Mode, Is.EqualTo(AppMode.PackSelect));
Assert.That(flow.Status, Is.EqualTo("SELECT A FOLDER OR ZIP TO BEGIN"));
}
[Test]
public void PlayFromHome_PreservesTheSelectedPackAndExplainsRegisteredContent()
{
var flow = new AppFlowController();
flow.TransitionTo(AppMode.Home);
flow.TryBeginPlayFromHome(1, 3);
Assert.That(flow.PackSelection, Is.EqualTo(1));
Assert.That(flow.Status, Is.EqualTo("3 BMS PACKS REGISTERED"));
}
[TestCase(AppMode.Loading)]
[TestCase(AppMode.Gameplay)]
[TestCase(AppMode.PackSelect)]
public void HomePlayAction_DoesNotInterruptAnotherScreen(AppMode mode)
{
var flow = new AppFlowController();
flow.TransitionTo(mode);
Assert.That(flow.TryBeginPlayFromHome(), Is.False);
Assert.That(flow.Mode, Is.EqualTo(mode));
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d5440faaa6cf473bb016ed8a641239b2
@@ -7,6 +7,7 @@ using ProjectQuint.Core.Progression;
using ProjectQuint.Library;
using ProjectQuint.Persistence;
using ProjectQuint.Bootstrap;
using ProjectQuint.Features.Profile;
using UnityEngine;
namespace ProjectQuint.Tests
@@ -230,7 +231,7 @@ namespace ProjectQuint.Tests
})
.ToArray();
var contexts = ProjectQuintApp.BuildProfileRatingContexts(
var contexts = ProfileRatingContextBuilder.Build(
new[] { recorded },
tables,
Array.Empty<SongLibraryEntry>(),
@@ -0,0 +1,70 @@
using System;
using NUnit.Framework;
using ProjectQuint.Core.Progression;
using ProjectQuint.Features.Profile;
namespace ProjectQuint.Tests
{
internal sealed class ProfileSessionControllerTests
{
[Test]
public void RatingSelection_WrapsAndRefreshClampsToAvailableContexts()
{
var session = new ProfileSessionController();
var contexts = new[]
{
new RatingSummary("first", "FIRST", 4, false, 1, 1),
new RatingSummary("second", "SECOND", 7, true, 2, 1),
};
session.Refresh(default, null, null, contexts, default);
session.ShiftRatingContext(-1);
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("second"));
session.Refresh(default, null, null, Array.Empty<RatingSummary>(), contexts[0]);
Assert.That(session.RatingContextIndex, Is.Zero);
Assert.That(session.RatingSummary.ProviderId, Is.EqualTo("first"));
}
[Test]
public void CollectionSelection_ClampsAndSurvivesAnEmptyRefresh()
{
var session = new ProfileSessionController();
var groups = new[] { default(CollectionGroupSummary), default(CollectionGroupSummary) };
session.Refresh(default, new ProfileCollectionSnapshot(default, groups, null, null),
null, null, default);
session.SelectCollection(0, 100);
Assert.That(session.CollectionSelections[0], Is.EqualTo(1));
session.ShiftCollectionSelection(1);
Assert.That(session.CollectionSelections[0], Is.Zero);
session.ShiftCollectionColumn(-1);
Assert.That(session.CollectionColumn, Is.EqualTo(2));
session.Refresh(default, null, null, null, default);
session.SelectCollection(-1, -1);
Assert.That(session.CollectionColumn, Is.Zero);
Assert.That(session.CollectionSelections, Is.EqualTo(new[] { 0, 0, 0 }));
}
[Test]
public void Modal_ClampsTabsAndCloseIsIdempotent()
{
var session = new ProfileSessionController();
session.Open(100);
Assert.That(session.IsOpen, Is.True);
Assert.That(session.Tab, Is.EqualTo(3));
session.ShiftTab(1);
Assert.That(session.Tab, Is.Zero);
session.Close();
session.Close();
Assert.That(session.IsOpen, Is.False);
}
[Test]
public void EmptyBadgeCollection_DoesNotOfferAPersistableSelection()
{
var session = new ProfileSessionController();
session.SelectBadge(100);
session.ShiftBadge(-1);
Assert.That(session.BadgeSelection, Is.Zero);
Assert.That(session.SelectedBadgeCourseId, Is.Empty);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc915c84e7104cebbd016972b39e1362
@@ -24,7 +24,7 @@ namespace ProjectQuint.Tests
Is.True,
error);
Assert.That(manifest.buildNumber, Is.GreaterThan(ProjectQuintReleaseVersion.BuildNumber));
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.True);
Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.True);
}
[TestCase("\"schemaVersion\":1", "\"schemaVersion\":2")]
@@ -57,13 +57,13 @@ namespace ProjectQuint.Tests
out _);
Assert.That(
ProjectQuintUpdateService.ShouldOffer(
UpdatePolicy.ShouldOffer(
manifest,
manifest.version,
manual: false),
Is.False);
Assert.That(
ProjectQuintUpdateService.ShouldOffer(
UpdatePolicy.ShouldOffer(
manifest,
manifest.version,
manual: true),
@@ -76,17 +76,17 @@ namespace ProjectQuint.Tests
var now = new DateTime(2026, 7, 29, 12, 0, 0, DateTimeKind.Utc);
Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically(
UpdatePolicy.ShouldCheckAutomatically(
now.AddHours(-23).Ticks,
now.Ticks),
Is.False);
Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically(
UpdatePolicy.ShouldCheckAutomatically(
now.AddHours(-24).Ticks,
now.Ticks),
Is.True);
Assert.That(
ProjectQuintUpdateService.ShouldCheckAutomatically(0, now.Ticks),
UpdatePolicy.ShouldCheckAutomatically(0, now.Ticks),
Is.True);
}
@@ -101,7 +101,7 @@ namespace ProjectQuint.Tests
out var error),
Is.True,
error);
Assert.That(ProjectQuintUpdateService.IsUpdateAvailable(manifest), Is.False);
Assert.That(UpdatePolicy.IsUpdateAvailable(manifest), Is.False);
}
[Test]
@@ -0,0 +1,118 @@
using NUnit.Framework;
using ProjectQuint.Core.Release;
using ProjectQuint.Features.Flow;
using ProjectQuint.Features.Updates;
namespace ProjectQuint.Tests
{
internal sealed class UpdateSessionControllerTests
{
private static ProjectQuintUpdateManifest NewerBuild() => new ProjectQuintUpdateManifest
{
available = true,
version = "0.1.0-alpha.99",
buildNumber = ProjectQuintReleaseVersion.BuildNumber + 1,
};
[Test]
public void DuplicateCheck_IsRejectedUntilTheCurrentCheckCompletes()
{
var session = new UpdateSessionController();
Assert.That(session.TryBeginCheck(true, out var id), Is.True);
Assert.That(session.TryBeginCheck(true, out _), Is.False);
Assert.That(session.CompleteCheck(id,
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.True);
Assert.That(session.IsChecking, Is.False);
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.EqualTo(100));
Assert.That(session.CompleteCheck(id,
new ProjectQuintUpdateCheckResult(false, null, "late failure"), "", 200), Is.False);
Assert.That(session.Error, Is.Empty);
}
[Test]
public void CancelledCheck_CannotOverwriteAReplacementCheck()
{
var session = new UpdateSessionController();
session.TryBeginCheck(false, out var oldId);
session.Cancel();
session.TryBeginCheck(true, out var newId);
Assert.That(session.CompleteCheck(oldId,
new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100), Is.False);
session.AbortCheck(oldId);
Assert.That(session.IsChecking, Is.True);
Assert.That(session.CompleteCheck(newId,
new ProjectQuintUpdateCheckResult(false, null, "network unavailable"), "", 200), Is.True);
Assert.That(session.Error, Is.EqualTo("network unavailable"));
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
}
[TestCase(AppMode.Gameplay, false, false)]
[TestCase(AppMode.Loading, false, false)]
[TestCase(AppMode.Home, true, false)]
[TestCase(AppMode.Options, true, false)]
[TestCase(AppMode.Home, false, true)]
[TestCase(AppMode.Options, false, true)]
public void Offer_WaitsForAnUnblockedHomeOrOptionsScreen(AppMode mode, bool blocked, bool expected)
{
var session = new UpdateSessionController();
session.TryBeginCheck(false, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, NewerBuild(), ""), "", 100);
Assert.That(session.TryPresent(mode, blocked), Is.EqualTo(expected));
Assert.That(session.IsModalOpen, Is.EqualTo(expected));
Assert.That(session.TryPresent(mode, blocked), Is.False);
}
[Test]
public void SkippedVersion_IsSilentAutomaticallyButCanBeRequestedManually()
{
var session = new UpdateSessionController();
var manifest = NewerBuild();
session.TryBeginCheck(false, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 100);
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
session.TryBeginCheck(true, out id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, manifest, ""), manifest.version, 200);
Assert.That(session.TryPresent(AppMode.Home, false), Is.True);
session.MoveSelection(-1);
Assert.That(session.Selection, Is.EqualTo(2));
session.Dismiss();
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
}
[Test]
public void AutomaticCheck_StartsOnceAtHomeAndNeverInTheEditor()
{
var session = new UpdateSessionController();
Assert.That(session.TryBeginAutomaticCheck(AppMode.Gameplay, false, 0, 100, out _), Is.False);
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 100, out _), Is.True);
session.Cancel();
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false, 0, 200, out _), Is.False);
var editor = new UpdateSessionController();
Assert.That(editor.TryBeginAutomaticCheck(AppMode.Home, true, 0, 100, out _), Is.False);
}
[Test]
public void AutomaticCheck_RespectsTheLastSuccessfulCheckTime()
{
var session = new UpdateSessionController();
var now = System.DateTime.UtcNow;
Assert.That(session.TryBeginAutomaticCheck(AppMode.Home, false,
now.AddHours(-23).Ticks, now.Ticks, out _), Is.False);
Assert.That(session.IsChecking, Is.False);
}
[Test]
public void SuccessWithoutAManifest_IsAnObservableFailure()
{
var session = new UpdateSessionController();
session.TryBeginCheck(true, out var id);
session.CompleteCheck(id, new ProjectQuintUpdateCheckResult(true, null, ""), "", 100);
Assert.That(session.LastSuccessfulCheckUtcTicks, Is.Zero);
Assert.That(session.Error, Is.EqualTo("UPDATE MANIFEST IS MISSING"));
Assert.That(session.TryPresent(AppMode.Home, false), Is.False);
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cd82750b60d84a0f980a0721221c1f9d