447 Commits

Author SHA1 Message Date
c023767df9 bump to 2025.1031.0-tachyon + misc changes for debug builds 2025-11-08 16:26:06 +03:00
Bartłomiej Dach
050c10cec2 Ensure all invocations of spectator server hub methods have their errors observed (#35488)
Fell out when attempting
https://github.com/ppy/osu-server-spectator/pull/346.

Functionally, if a true non-`HubException` is produced via an invocation
of a spectator server hub method, this doesn't really do much - the
error will still log as 'unobserved' due to the default handler, it will
still show up on sentry, etc. The only difference is that it'll get
handled via the continuation installed in `FireAndForget()` rather than
the `TaskScheduler.UnobservedTaskException` event.

The only real case where this is relevant is when the server throws
`HubException`s, which will now instead bubble up to a more
human-readable form. Which is relevant to the aforementioned PR because
that one makes any hub method potentially throw a `HubException` if the
client version is too old.

Obviously this does nothing for the existing old clients.
2025-10-29 12:18:23 +09:00
De4n
b4fd7ec10f Add a keycounter that has been actually used in Triangles skin (#35491) 2025-10-29 12:18:00 +09:00
Bartłomiej Dach
cbe7da99ad Fix screen footer overlay content being pushed to right during fade-out (#35481)
* Apply some renames & drawable names for visualiser

Optional but really helps me make heads of tails as to what anything is
here.

Like really, multiple variations of `footerContent` inside a
`ScreenFooter` class, with zero elaboration that it's really content to
do with *overlays*...

* Fix screen footer overlay content being pushed to right during fade-out

- Closes https://github.com/ppy/osu/issues/35203
- Supersedes / closes https://github.com/ppy/osu/pull/35468
2025-10-29 12:14:37 +09:00
Dean Herbert
b1a421c22b Merge pull request #35470 from smoogipoo/qp-show-quit-users 2025-10-28 19:26:10 +09:00
Dean Herbert
9601708087 Fix quit text on avatar only mode, fix avatar fade 2025-10-28 18:29:05 +09:00
Dean Herbert
22f11b6fa5 Update test in line with new quit panel behaviour 2025-10-28 16:30:31 +09:00
Bartłomiej Dach
f95d0d214e Merge pull request #35474 from peppy/fix-wasapi-setting-text
Fix WASAPI settings notice text not displaying on startup
2025-10-28 08:23:03 +01:00
Dean Herbert
0205cf0fb9 Render frame buffers at a higher resolution to fix blurry for now 2025-10-28 15:43:25 +09:00
Dean Herbert
8b2b6517ca Fix regression of avatar animation 2025-10-28 15:41:40 +09:00
Dean Herbert
ce3b8bc77b Update framework 2025-10-28 15:15:35 +09:00
Dean Herbert
98829bf857 Merge pull request #35249 from dnfd1/mania-difficultychange-limits
Adjust extended OD limits for mania difficulty change mod to reflect HR and EZ values
2025-10-28 15:05:26 +09:00
Dan Balasescu
3c37ac1718 Fix clipped outline
Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
2025-10-27 23:19:28 +09:00
Dean Herbert
f8769d2e44 Fix WASAPI settings notice text not displaying on startup 2025-10-27 22:59:02 +09:00
Dean Herbert
4d66762898 Merge pull request #35471 from bdach/single-result-reselected
Fix single filtered selection not being reselected after being filtered away
2025-10-27 22:11:26 +09:00
Bartłomiej Dach
e61ae7ab8a Fix single filtered selection not being reselected after being filtered away
Closes https://github.com/ppy/osu/issues/35003.

Bit dodgy to use `CurrentSelectionItem` for this. Ideally I would use
the global `Beatmap.IsDefault`, but I kind of don't want to violate the
rule that `BeatmapCarousel` shouldn't have direct access to the global
beatmap. And this seems to work, so... maybe fine to use until it
doesn't?
2025-10-27 11:13:48 +01:00
Bartłomiej Dach
98eb29c43d Add failing test 2025-10-27 11:13:47 +01:00
Dan Balasescu
bb578d254d Mark panels as quit instead of removing 2025-10-27 19:07:09 +09:00
Dan Balasescu
b7c07ad0e5 Add support for marking panels as quit 2025-10-27 19:07:09 +09:00
Dan Balasescu
08621c4cc9 Refactor panel structure 2025-10-27 19:07:05 +09:00
Bartłomiej Dach
be9b99f975 Merge pull request #35467 from smoogipoo/qp-discord-presence
Adjust Discord rich presence for quick play
2025-10-27 09:11:20 +01:00
Dan Balasescu
765b9a20b5 Hide quick play room name in Discord rich presence 2025-10-27 12:13:15 +09:00
Dan Balasescu
473fb5720c Disable Discord invites to quick play rooms 2025-10-27 12:11:11 +09:00
Dean Herbert
5faf791ca0 Merge pull request #35445 from peppy/experimental-wasapi-user-toggle
Add settings toggle for experimental BASS initialisation mode
2025-10-25 20:44:53 +09:00
Dean Herbert
9ca47fc53a Update framework 2025-10-25 19:41:28 +09:00
Dean Herbert
79a76ce587 Update AudioDevicesSettings.cs
Co-authored-by: Dan Balasescu <smoogipoo@smgi.me>
2025-10-25 17:35:48 +09:00
Dean Herbert
72fa1553c3 Add settings toggle for experimental BASS initialisation mode 2025-10-25 13:46:49 +09:00
Dean Herbert
954061b00b Merge pull request #35432 from smoogipoo/improve-qp-chat
Several improvements to quick play chat input
2025-10-25 00:50:11 +09:00
Dean Herbert
b74962af92 Merge pull request #35422 from bdach/favourites-grouping
Implement grouping by favourites
2025-10-24 23:24:48 +09:00
Dean Herbert
1af462b692 Add very simple countdown timer for quick play stages (#35433) 2025-10-24 23:07:04 +09:00
Dean Herbert
763739e877 Merge branch 'master' into favourites-grouping 2025-10-24 22:39:14 +09:00
Dan Balasescu
f96be84c57 Fix tests 2025-10-24 22:23:05 +09:00
Dan Balasescu
613c208362 Fix partially offscreen quick play chat context menu 2025-10-24 21:45:56 +09:00
Dan Balasescu
a3c78de710 Move context menu from channel to chat overlay 2025-10-24 21:45:56 +09:00
Dan Balasescu
e240817087 Move quick play chat entirely to screen footer 2025-10-24 21:45:56 +09:00
Dean Herbert
ddb844f81b Merge pull request #35429 from bdach/scrolling-song-select-wedge-text
Scroll song select title wedge text if it overflows
2025-10-24 21:40:03 +09:00
Dean Herbert
b6f4175939 Merge pull request #35431 from bdach/instant-scroll-carousel-post-filter
SongSelectV2: Scroll to selection instantly after a filter
2025-10-24 21:27:25 +09:00
Bartłomiej Dach
819da1bc38 SongSelectV2: Scroll to selection instantly after a filter
Closes https://github.com/ppy/osu/issues/33379.

Pretty sure this matches song select V1. The two call sites where the
old one does instant scrolls are:

	30412ba3f2/osu.Game/Screens/Select/BeatmapCarousel.cs (L672)

which happens just after a filter, and

	30412ba3f2/osu.Game/Screens/Select/BeatmapCarousel.cs (L683)

which is a bit more difficult to pin down, but generally appears to
happen on changes to the visible items, which on `SongSelectV2` triggers
a re-filter anyway.
2025-10-24 13:24:47 +02:00
Bartłomiej Dach
33e42d2809 Fix code quality 2025-10-24 12:11:38 +02:00
Bartłomiej Dach
1a49d030a0 Fix marquee container not updating scrolling state if its content changes size
This is actually possible in current usages if you e.g. toggle "use
original metadata" on/off which will change the width of the underlying
sprite texts. Or by setting window size. Pick your poison.
2025-10-24 11:08:01 +02:00
Bartłomiej Dach
03adae4417 Scroll song select title wedge text if it overflows
Instead of truncating.

Addresses https://github.com/ppy/osu/discussions/35404.

The one "tiny" problem is that the "click to search" functionality of
these texts is maybe a bit worse now, because the clickable target is
now the full width of the wedge rather than autosized to the text.
Salvaging this is *maybe* possible, but *definitely* annoying, so I'd
rather not frontload it.
2025-10-24 11:07:06 +02:00
Dean Herbert
5a38e1537b Merge pull request #35428 from bdach/star-rating-grouping-limit
Use single group for beatmaps of 15 stars and above
2025-10-24 18:00:57 +09:00
Dean Herbert
6ac8e59a11 Merge pull request #35426 from bdach/collection-grouping-order
Fix song select collection group order not matching other collection lists when certain characters are used
2025-10-24 17:53:28 +09:00
Bartłomiej Dach
a816977208 Use single group for beatmaps of above 15 stars
Addresses https://github.com/ppy/osu/discussions/35201.
2025-10-24 10:23:36 +02:00
Bartłomiej Dach
f0bfb4becc Assert expected behaviour in test 2025-10-24 10:23:36 +02:00
Dean Herbert
ec6ffb3403 Merge pull request #35420 from smoogipoo/qp-handle-beatmap-present
Disable presenting beatmaps during quick play
2025-10-24 16:59:45 +09:00
Bartłomiej Dach
6ded2e3de7 Fix song select collection group order not matching other collection lists when certain characters are used
Addresses https://github.com/ppy/osu/discussions/35391.

See inline commentary for explanation.
2025-10-24 09:51:34 +02:00
Dean Herbert
6c7eafde1a Merge pull request #35395 from bdach/culture-info-is-the-bane-of-my-existence
Allow `NumberFormattingExtensions.ToStandardFormattedString()` to accept culture
2025-10-24 16:46:37 +09:00
Bartłomiej Dach
14d0982b6c Implement grouping by favourites
- Closes https://github.com/ppy/osu/issues/34494.
- Supersedes / closes https://github.com/ppy/osu/pull/34744.
2025-10-24 08:46:34 +02:00
Bartłomiej Dach
29787360ba Change BeatmapCarouselFilterGrouping constructor params to required init properties 2025-10-24 08:42:45 +02:00
Bartłomiej Dach
6b56a0611b Refetch list of user favourites on every change to favourites
Is this lazy? Sure it is. Friends and blocks do the same thing, though,
and I'm not overthinking this any more than I already have.

Being smarter here would likely mean being more invasive with respect to
listening in on all outgoing API requests and silently updating
favourites on that basis. Which is "smart" but also complicated.
2025-10-24 08:41:52 +02:00
Bartłomiej Dach
0f1bf35bd9 Add favourite beatmap set tracking to LocalUserInfo 2025-10-24 08:40:50 +02:00
Bartłomiej Dach
b600860540 Implement request & response for fetching logged in user's favourite beatmap sets 2025-10-24 08:36:13 +02:00
Bartłomiej Dach
ae7ba034a7 Always use current culture in ToStandardFormattedString() 2025-10-24 08:26:01 +02:00
Dean Herbert
0cb521798d Merge pull request #35398 from bdach/song-select-working-beatmap-pressure
Attempt to improve performance of beatmap carousel when not grouped by sets
2025-10-24 15:22:41 +09:00
Dan Balasescu
1ee24521b8 Disable presenting beatmaps during matchmaking 2025-10-24 15:15:46 +09:00
Dean Herbert
efe7d92096 Merge branch 'master' into song-select-working-beatmap-pressure 2025-10-24 14:36:59 +09:00
Dean Herbert
4f478db056 Merge pull request #35400 from bdach/leaderboard-sometimes-incorrectly-partial
Fix solo leaderboard sometimes not showing user position while it technically could
2025-10-24 14:36:14 +09:00
Dean Herbert
646cb71b77 Merge pull request #35415 from bdach/local-user-state-refactor
Extract all pieces of local user-related state to `APIAccess` subcomponent
2025-10-24 14:32:43 +09:00
Dean Herbert
ce3e3d17fa Merge pull request #35393 from smoogipoo/fix-qp-availability
Make quick play redownload locally modified beatmaps
2025-10-24 14:28:03 +09:00
Dean Herbert
d062c99666 Merge branch 'master' into fix-qp-availability 2025-10-24 14:27:45 +09:00
Dean Herbert
803737c947 Use more legible collection initialisation 2025-10-24 14:22:10 +09:00
Dean Herbert
44a427f4e5 Merge pull request #35399 from smoogipoo/qp-view-beatmap
Fix quick play "view beatmap" not showing beatmap overlay
2025-10-24 14:21:49 +09:00
Dean Herbert
85ab0d621c Merge pull request #35397 from smoogipoo/qp-match-ended-counter
Fix round counter showing on match end
2025-10-24 14:17:09 +09:00
Dean Herbert
1591d49143 Merge pull request #35401 from bdach/custom-sample-set-model-support
Adjust gameplay sample models to support custom sample sets
2025-10-23 18:36:17 +09:00
Dean Herbert
b9de7ba196 Merge pull request #35367 from smoogipoo/qp-idle-track
Preview next song in quick play
2025-10-23 18:23:20 +09:00
Bartłomiej Dach
dfa8d4fe7c Fix blocks not being correctly cleared on logout
See, this refactor is where omissions like this that normally would pass
unnoticed stop passing unnoticed.
2025-10-23 10:54:52 +02:00
Bartłomiej Dach
5eda9a0fd7 Extract all pieces of local user-related state to APIAccess subcomponent
Something I've asked to be done for a long time. Relevant because I've
complained about this on every addition of a new piece of user-local
state: friends, blocks, and now favourite beatmaps.

It's just so messy managing all this inside `APIAccess` next to
everything else, IMO.
2025-10-23 10:53:58 +02:00
Bartłomiej Dach
ca8fc81a7e Fix solo leaderboard sometimes not showing user position while it technically could
The "partial" leaderboard logic in `SoloGameplayLeaderboardProvider`
always assumed the online fetch would request 50 scores, which is no
longer the case after https://github.com/ppy/osu/pull/33100.
2025-10-23 08:53:29 +02:00
Bartłomiej Dach
82bcbb53dd Fix taiko sample models not passing everything forward as they should 2025-10-23 08:48:39 +02:00
Dan Balasescu
9a089315b8 Don't block input on mouse down 2025-10-22 22:22:44 +09:00
Bartłomiej Dach
baea05a0a1 Adjust test to cover better 2025-10-22 14:58:13 +02:00
Bartłomiej Dach
c361e6d3c2 Adjust gameplay sample models to support custom sample sets
This is a set of model changes which is supposed to facilitate support
for custom sample sets to the beatmap editor that is on par with stable.

It is the minimal set of changes. Because of this, it can probably be
considered "ugly" or however else you want to put it - but before you
say that, I want to try and pre-empt that criticism by explaining where
the problems lie.

Problem #1: duality in sample models
---

There is currently a weird duality of what a `HitObject`'s samples will
be.

- If an object has just been placed in the editor, and not saved /
  decoded yet, it will use `HitSampleInfo`.

- If an object has already been encoded to the beatmap at least once, it
  will use `ConvertHitObjectParser.LegacyHitSampleInfo`.

As long as that state of affairs remains, `HitSampleInfo` must be able
to represent anything that `LegacyHitSampleInfo` can, if feature parity
is to be achieved.

Problem 2: The 0 & 1 sample banks
---

Custom sample banks of 2 and above are a pretty clean affair. They map to
a suffix on the sample filename, and said samples are allowed to be
looked up from the beatmap skin. `Suffix` already exists in
`HitSampleInfo`.

However, the 1 custom sample bank is evil. It uses *non-suffixed*
samples, *allows lookups from the beatmap skins*, contrary to no bank /
bank 0, which *also* uses non-suffixed samples, but *doesn't* allow them
to be looked up from the beatmap skin.

This is why `HitSampleInfo.UseBeatmapSamples` has been called to
existence - without it there is no way to represent the ability of using
or not using the beatmap skin assets.

As has been stated previously in discussions about this feature, it's
both a *mapping* and a *skinning* concern.

There are many things you could do about either of these problems, but I
am pretty sure tackling either one is going to take *many* more lines of
code than this commit does. Which is why this is the starting point of
negotiation.
2025-10-22 14:58:08 +02:00
Bartłomiej Dach
a4a2e4e639 Add test coverage of expected behaviour of sample suffix 2025-10-22 14:30:47 +02:00
Dan Balasescu
5fc8cde0f7 Fix AllowSelection blocking all input 2025-10-22 19:55:43 +09:00
Dan Balasescu
a2098b633a Fix quick play "view beatmap" not showing beatmap overlay 2025-10-22 19:29:51 +09:00
Dan Balasescu
0a3665c43d Fix round counter showing on match end 2025-10-22 19:01:02 +09:00
Bartłomiej Dach
c34b2ffc05 Fix performance overhead when computing spacing between standalone panels
The equality check that was supposed to replace the read of
`CurrentSelectionItem` showed up as a hotspot in profiling.

The selection updating code looks a little stupid after this, but all in
the name of performance...
2025-10-22 11:46:34 +02:00
Bartłomiej Dach
4ebd97b804 Slightly delay retrieval of working beatmaps in song select panels
Beatmap panels can be visible for very brief instants.
`PanelSetBackground` has a backstop to prevent expensive background
loads which is based on the position of the panel relative to centre of
screen.

However, retrieving the working beatmap that *precedes* any of that
expensive background load logic, is *also* expensive, and *always* runs
even if a panel is visible on screen for only a brief second. Therefore,
by moving some of that background load delay towards delaying retrieving
the working beatmap, we can save on doing even more work, which has
beneficial implications for performance.
2025-10-22 11:00:20 +02:00
Dean Herbert
dcb30ed5b3 Add pool names to quick play pool selector (#35394) 2025-10-22 17:54:49 +09:00
Bartłomiej Dach
a29a5ab7e6 Allow NumberFormattingExtensions.ToStandardFormattedString() to accept culture
I had previously made it invariant in
https://github.com/ppy/osu/pull/32837, and in another instance of past
me being an asshole, I can't actually find the reasoning for this at
this time.

That said, you'd be excused for thinking "why does this matter"? Well,
this will fix https://github.com/ppy/osu/issues/35381, because that
failure only occurs when the user's culture is set to one that doesn't
use a decimal point (.) but rather a decimal comma (,). This messes with
framework, which uses the *current* culture to check for decimal
separator rather than invariant:

d3226a7842/osu.Framework/Graphics/UserInterface/TextBox.cs (L106-L111)

An alternative would be to change framework instead to always accept the
invariant decimal separator.

God I hate this culture crap.
2025-10-22 10:31:12 +02:00
Dan Balasescu
62599de649 Fix intermittent footer test failures
See:
https://github.com/ppy/osu/pull/35367/checks?check_run_id=53269836615

Can be reproed via a `Thread.Sleep(1000)` in a `TestScreenOne` BDL load.
Code here is similar to `OsuGameTestScene.PushAndConfirm()`.
2025-10-22 16:40:20 +09:00
Dan Balasescu
4cac1781c5 Use interface instead 2025-10-22 16:02:46 +09:00
Dan Balasescu
fcf6d04791 Download using preferred video mode 2025-10-22 15:34:40 +09:00
Dan Balasescu
c6fcba7e1c Fix quick play not redownloading modified beatmaps 2025-10-22 15:34:29 +09:00
Dean Herbert
47b95de06d Merge pull request #35365 from bdach/bump-diffcalc-versions
Bump difficulty calculator versions
2025-10-22 15:12:56 +09:00
Dean Herbert
4e4eba2070 Merge pull request #35388 from Hoopsier/master
Accuracy to pause
2025-10-22 14:31:23 +09:00
Dean Herbert
2ec0cbe5db Allow localisation of accuracy display on pause screen 2025-10-22 13:56:38 +09:00
Hoopsy
0644543e28 Accuracy to pause and fail 2025-10-22 00:29:55 +03:00
Dan Balasescu
23933452fe Preserve last beatmap until selection is made 2025-10-21 20:00:21 +09:00
Dean Herbert
61547d6b38 Merge pull request #35377 from peppy/update-framework
Update framework
2025-10-21 18:58:42 +09:00
Dean Herbert
0b84916c3e Update framework 2025-10-21 17:24:33 +09:00
Dean Herbert
3120871298 Merge pull request #35366 from bdach/carousel-collapsing-broke
Fix beatmap set not expanding post-filter if grouping was turned off after manually collapsing active group
2025-10-20 20:22:56 +09:00
Dan Balasescu
2dbfdc3d2c Preview next song in quick play 2025-10-20 17:02:35 +09:00
Bartłomiej Dach
0af3f15c7c Merge pull request #35313 from smoogipoo/subscreen-footer
Make `ScreenFooter` support subscreens
2025-10-20 10:00:36 +02:00
Bartłomiej Dach
594b8c1a60 Fix beatmap set not expanding post-filter if grouping was turned off after manually collapsing active group
Closes https://github.com/ppy/osu/issues/35339.
2025-10-20 09:30:02 +02:00
Bartłomiej Dach
0269257287 Add failing test 2025-10-20 09:23:06 +02:00
Bartłomiej Dach
03a5aedf99 Bump difficulty calculator versions
To trigger client-side recalculations of star ratings.

Should have been done in https://github.com/ppy/osu/pull/35029.

Probably closes https://github.com/ppy/osu/issues/35357.
2025-10-20 08:46:30 +02:00
Dean Herbert
fbaf27e3db Merge pull request #35342 from peppy/revert-framework
Revert framework bump to fix crashes for some users
2025-10-19 00:39:43 +09:00
Dean Herbert
45eb34ecde Merge pull request #35335 from peppy/fix-quick-play-user-panels
Fix quick play player panels being hard to see against bright user backgrounds
2025-10-19 00:39:37 +09:00
Dean Herbert
10beab6ad3 Revert framework bump to fix crashes for some users 2025-10-18 23:53:45 +09:00
Dean Herbert
70188d4fab Fix quick play player panels being hard to see against bright user backgrounds 2025-10-18 23:18:57 +09:00
Dean Herbert
1ce846294b Update framework 2025-10-18 19:08:07 +09:00
Dan Balasescu
4be29425a0 Use new implementation in ScreenTestScene 2025-10-17 19:01:28 +09:00
Dan Balasescu
db3cc583e6 Isolate footer behaviour to ScreenStackFooter, support subscreens 2025-10-17 19:01:28 +09:00
Bartłomiej Dach
fec7ef7624 Merge pull request #35316 from kennyaja/round_control_points
Round slider control points to integer positions (instead of truncating them)
2025-10-17 11:46:52 +02:00
Bartłomiej Dach
6e378ad5af Update relevant test with new expectations 2025-10-17 08:58:09 +02:00
Bartłomiej Dach
8f8f605748 Use MathF instead of casting 2025-10-17 08:44:58 +02:00
Bartłomiej Dach
5f9ade6610 Fix code quality 2025-10-17 08:44:30 +02:00
Bartłomiej Dach
1a569e9e2a Merge pull request #35317 from peppy/locus-bundle
Add locus 2025 winners to bundled download beatmaps list
2025-10-16 20:16:40 +02:00
Dean Herbert
98762ce09e Add locus 2025 winners to bundled download beatmaps list 2025-10-17 02:03:52 +09:00
Alban Cabannes-Michel
8f6f859c15 SSV2 : Replace "Mark as Played" with "Remove from Played" if map is already played (#35287)
* SSV2 : Replace Mark as Played with Remove from Played if already played

* Remove checks of BeatmapInfo.LastPlayed for DateTimeOffset.MinValue

* Make FooterButtonOptions use a RealmLive<BeatmapInfo> and act on review comments

* FIXUP: Detach BeatmapInfo before passing it to FooterButtonOptions.Popover

---------

Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
2025-10-16 14:59:16 +02:00
kennyaja
cf1834b080 Also round slider control points to integer positions if it has less than 2 segments 2025-10-16 19:25:43 +07:00
Bartłomiej Dach
a2bae15db1 Fix Hold Off mod changing scroll speed in rare scenarios (#35265)
Reported (in a rather confusing manner) in
https://discord.com/channels/188630481301012481/1097318920991559880/1426084740783538268.

The relevant bit here is the following logic:

	32c60bfb36/osu.Game/Rulesets/UI/Scrolling/DrawableScrollingRuleset.cs (L111-L118)

which mania enables.
2025-10-16 21:09:52 +09:00
Dean Herbert
159349a64b Merge pull request #35312 from smoogipoo/fix-mp-exit
Fix screen event misusages in multplayer/matchmaking
2025-10-16 18:19:01 +09:00
Dan Balasescu
81a529200d Add footer tests involving subscreens 2025-10-16 17:38:51 +09:00
Dan Balasescu
6fa4a7152f Refactor resuming / exiting events 2025-10-16 17:05:52 +09:00
Dan Balasescu
508c37c27b Fix missing entering / suspending events 2025-10-16 17:05:52 +09:00
Dan Balasescu
95f86a5c0e Fix matchmaking calling OnExiting() twice
Once prior to the confirmation dialog being displayed, then a second
time after the exit is confirmed.
2025-10-16 17:05:46 +09:00
Dan Balasescu
cd62f66d79 Fix multiplayer calling OnExiting() twice
Once via explicit call to `OnExiting()`, and the second time via
`.Exit()`.
2025-10-16 17:02:20 +09:00
kennyaja
3fbe777f33 Round instead of truncating slider control points to integer positions 2025-10-16 14:44:44 +07:00
Dean Herbert
b8ceb91a78 Merge pull request #35236 from tadatomix/song-select/coloured-group-ranked-status
Colour Ranked Status panel to the related status
2025-10-15 16:56:45 +09:00
Dean Herbert
9f61284371 Merge pull request #35300 from bdach/show-leaderboard-in-solo-spectator
Show leaderboard in solo spectator
2025-10-14 22:55:55 +09:00
Dean Herbert
3f18a1312e Merge pull request #35263 from bdach/user-tag-not-equals-filter
Fix not-equals user, artist, and title tag filters not working
2025-10-14 22:29:09 +09:00
Bartłomiej Dach
17758d5934 Merge pull request #34845 from diquoks/localisation/binding-settings
Localise "back" button in `BindingSettings`
2025-10-14 13:00:26 +02:00
Bartłomiej Dach
eaebe99a5c Merge pull request #35273 from diquoks/localisation/play
Localise `Break` & `PlayerSettings` on `Play` screen
2025-10-14 12:57:45 +02:00
Bartłomiej Dach
1edbb1d586 Show leaderboard in solo spectator
Closes https://github.com/ppy/osu/issues/35293.

The removed comment here says that "there's no guarantee" that
`LeaderboardManager` has the correct scores:

	a060ddb543/osu.Game/Screens/Play/SpectatorPlayer.cs (L22-L25)

but at least now, that's not correct because this is ensured via

	a060ddb543/osu.Game/Screens/Play/PlayerLoader.cs (L280-L286)

through which the solo spectator player is pushed:

	a060ddb543/osu.Game/Screens/Play/SoloSpectatorScreen.cs (L239)

The empty leaderboard provider is still valid however in the case of
multiplayer spectator, because there we don't really ever want to be
showing any leaderboards on the individual player instances.
2025-10-14 11:50:55 +02:00
Dean Herbert
a060ddb543 Merge pull request #35262 from bdach/song-select-move-realm-fetches-off-thread
Move realm refetches of beatmap in song select wedges off of update thread
2025-10-13 22:57:54 +09:00
Dean Herbert
f488974d39 Improve design of quick play endgame results (#35267) 2025-10-13 22:07:21 +09:00
Bartłomiej Dach
8e01fb70c3 Fix artist/title keyword filters not working properly with not-equals operator
Closes https://github.com/ppy/osu/issues/35264.
2025-10-13 11:39:59 +02:00
Bartłomiej Dach
a3f635588c Fix exclusion filters filtering out empty strings 2025-10-13 11:39:38 +02:00
Bartłomiej Dach
f8d3285ab4 Add failing test coverage for artist text filters also not working correct 2025-10-13 11:22:29 +02:00
Bartłomiej Dach
a73c325235 Fix code quality 2025-10-13 10:19:19 +02:00
Bartłomiej Dach
97739c39e7 Attempt to ensure RealmAccess waits for the async read before disposing itself 2025-10-13 09:27:21 +02:00
Denis Titovets
b58f03bc36 Move PlayerSettingsStrings & revert "Creator" back to "Mapper" 2025-10-13 10:13:41 +03:00
Denis Titovets
d870547a18 Localise Back button on settings' sidebar 2025-10-13 10:02:13 +03:00
James Wilson
28c846b4d9 Reading bonus hotfix for Traceable mod (#35266)
* Pass slider factor to visibility bonus correctly for TC

* Decrease reading bonuses for TC
2025-10-13 13:34:57 +09:00
Dean Herbert
8e36533f65 Quick play forward design work (#35253)
* Remove unnecessary information from matchmaking beatmap panel

* Move avatar overlay inside card for better layout

* Allow higher jumping when jumping in succession

* Exclude player panel avatars from masking

* Adjust player panel animations a bit further

* Add avatar-only display mode

* Fix round warmup test not working

* Remove dead test scenes

* Fix edge case where users are added to not-yet-loaded card

* Decouple `PlayerPanel` from `UserPanel`

* Fix remaining test failure (and rename test to match new naming)
2025-10-13 13:28:09 +09:00
Denis Titovets
e183e6bb88 Make edits based on reviews 2025-10-11 11:03:53 +03:00
Denis Titovets
5dc44fbdf9 Add some localisation to Play screen 2025-10-11 11:03:40 +03:00
Bartłomiej Dach
60544bf595 Merge branch 'master' into song-select/coloured-group-ranked-status 2025-10-10 12:37:10 +02:00
Bartłomiej Dach
8f0b8153eb Adjust test
- Don't hardcode numerical enum bounds
- Exclude `Approved` as it doesn't show in real contexts
2025-10-10 12:34:51 +02:00
Bartłomiej Dach
3070a0068a Change graveyard colour yet again
Editorial decision. The "brighter" colour was still too dark to be able
to even see any semblance of shade, or the triangles in the background.
2025-10-10 12:34:28 +02:00
Bartłomiej Dach
c5e17f5f0f Actually throw error instead of weirdly using white 2025-10-10 12:31:56 +02:00
Bartłomiej Dach
8439e6ec6c Remove strange comments 2025-10-10 12:30:57 +02:00
Bartłomiej Dach
9ebc5b0a35 Merge pull request #35255 from dnfd1/rotate
Fix default origin in skin editor when rotating multiple objects
2025-10-10 12:19:53 +02:00
Bartłomiej Dach
0b00191d71 Check for cancellation EVEN MORE aggressively 2025-10-10 12:06:57 +02:00
Bartłomiej Dach
0389da4559 Attempt to abort realm accesses on disposal 2025-10-10 10:30:31 +02:00
Bartłomiej Dach
40c447a792 Fix not-equals user tag filters not working
Omission / oversight from https://github.com/ppy/osu/pull/34568.

Addresses https://github.com/ppy/osu/discussions/35260.
2025-10-10 09:56:39 +02:00
Bartłomiej Dach
4132a7cd53 Add failing test coverage for not-equals user tag filter 2025-10-10 09:55:59 +02:00
Bartłomiej Dach
ca4c033b76 Remove redundant refresh calls 2025-10-10 09:20:23 +02:00
Bartłomiej Dach
85bbe13aa9 Move realm refetches of beatmap in song select wedges off of update thread
From local testing on release build (such that online beatmaps are
accessible) with a large database it seems that maybe this'll help with
recurrent complaints of 'stutters'.

Co-authored-by: Dean Herbert <pe@ppy.sh>
2025-10-10 08:51:12 +02:00
Dean Herbert
1f2928bf2f Merge pull request #35252 from bdach/song-select-present-sometimes-fails
Fix wrong beatmap shown when presenting a beatmap from results screen
2025-10-10 14:54:50 +09:00
Dean Herbert
94c7489c1c Add some logging when FindWithRefresh triggers a slow realm refresh 2025-10-10 13:36:28 +09:00
tadatomix
3d6a3e3eda Remove unused OsuColor variable 2025-10-10 02:34:09 +03:00
tadatomix
a0f295d6fe Make Graveyard panel colour even brighter 2025-10-10 02:32:07 +03:00
dnfd1
3bb6685e75 Merge branch 'ppy:master' into rotate 2025-10-09 08:52:21 -07:00
dnfd1
e3459eccf2 convert origin of rotation to screen space for selections multiple objects in skin editor 2025-10-09 08:36:01 -07:00
dnfd1
0d68e5eeeb add inline comment to explain larger limits 2025-10-09 05:21:08 -07:00
dnfd1
faad1753a4 Round OD limits to -15 and 15 2025-10-09 05:13:17 -07:00
dnfd1
fed04eb63b Merge branch 'ppy:master' into mania-difficultychange-limits 2025-10-09 05:10:50 -07:00
Dean Herbert
52f82c7e56 Merge pull request #35240 from bdach/song-select-preserve-selection-on-update-attempt-3
Fix song select V2 not preserving selection after an update operation
2025-10-09 17:30:28 +09:00
Bartłomiej Dach
3fc3a53521 Fix weird xmldoc issue
Rider was fine with it...
2025-10-09 10:17:55 +02:00
Dean Herbert
2136d95087 Merge pull request #35251 from smoogipoo/fix-broken-tests
Fix test failures during individual runs
2025-10-09 17:09:01 +09:00
Dean Herbert
c58d13b802 Merge pull request #35250 from smoogipoo/fix-test-leaks
Fix test scene leaks through RealmRulesetStore/RealmAccess
2025-10-09 17:08:07 +09:00
Bartłomiej Dach
b477790d3e Fix wrong beatmap shown when presenting a beatmap from results screen
- Closes https://github.com/ppy/osu/issues/35023.
- Supersedes / closes https://github.com/ppy/osu/pull/35107.
2025-10-09 09:52:22 +02:00
Bartłomiej Dach
e33de9b658 Add test demonstrating failure scenario 2025-10-09 09:46:32 +02:00
Dan Balasescu
6eaf91d31a Fix test failures during individual runs 2025-10-09 16:34:55 +09:00
Bartłomiej Dach
6da6edd1d1 Fix shift-clicking not working on extra size beatmap cards
Was broken due to double assignment to `Action`.
2025-10-09 08:59:26 +02:00
Dan Balasescu
6af5158bb4 Fix undisposed Realm subscription 2025-10-09 15:56:34 +09:00
Dan Balasescu
79c367d208 Fix test scene leaks through RealmRulesetStore/RealmAccess 2025-10-09 15:28:15 +09:00
dnfd1
348713d83d Allow OD to be overrided 2025-10-08 21:27:49 -07:00
dnfd1
cab0b3451f Override OD setting to set extended limits for mania EZ and HR 2025-10-08 21:26:45 -07:00
Dean Herbert
91019dcdf7 Merge pull request #35241 from bdach/song-select-crash-on-missing-group
Fix carousel sometimes crashing when attempting to select next random set
2025-10-09 06:20:22 +09:00
De4n
0cc2e4eaa0 Change the pool of available Ranked Statuses
Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
2025-10-08 20:09:06 +03:00
Dean Herbert
422392233b Update framework 2025-10-08 23:18:35 +09:00
Bartłomiej Dach
7b1e3cd537 Fix carousel sometimes crashing when attempting to select next random set
I'm not exactly sure on the reproduction scenario here, but I have had
switching ruleset with converts disabled crash on me a few times
today. It appears to happen sometimes when after the switch the expanded
group no longer exists in the set mapping, because a filter just ran and
removed that group from set of possible groups because there'd be no
beatmaps under it.

I tried to manufacture a quick test but it's not a quick one to test
because filtering intereference is required to reproduce, I think.
I will try again on request but I mostly just want to get this fix out
ASAP before I finish up for the day.
2025-10-08 15:13:00 +02:00
Bartłomiej Dach
30412ba3f2 Fix song select V2 not preserving selection after an update operation
Because the detached store exists and has a chance to actually
semi-reliably intercept a beatmap update operation, I decided to try
this. It still uses a bit of a heuristic in that it checks for
transactions that delete and insert one beatmap each, but probably the
best effort thus far?

Notably old song select that was already doing the same thing locally to
itself got a bit broken by this, but with some tweaking that *looks* to
be more or less harmless I managed to get it unbroken. I'm not too
concerned about old song select, mind, mostly just want to keep it
*vaguely* working if I can help it.
2025-10-08 14:42:26 +02:00
Dean Herbert
4622d39f41 Merge pull request #35233 from diquoks/quick-fix/sfx-mute-on-restart
Mute SFX when holding restart beatmap bind
2025-10-08 21:39:07 +09:00
Dean Herbert
0d5c041cac Merge pull request #35234 from diquoks/quick-fix/spinner-rpm-layering
Fix `spinner-rpm` being layered above `spinner-spin` / `spinner-clear`
2025-10-08 21:21:21 +09:00
Dean Herbert
ffe8b9f4c3 Merge pull request #35239 from bdach/song-select-weirdness
Fix current beatmap set being incorrectly expanded after collapsing group with current selection
2025-10-08 21:19:08 +09:00
Bartłomiej Dach
9ba9966078 Fix current beatmap set being incorrectly expanded after collapsing group with current selection
Noticed in passing. See preceding commit for failure scenario.

Regressed in https://github.com/ppy/osu/pull/35163.
2025-10-08 13:42:43 +02:00
Bartłomiej Dach
f1ec04114f Add failing test 2025-10-08 13:42:31 +02:00
Bartłomiej Dach
4337046680 Add test assets covering correct layering of spinner SPM metre 2025-10-08 12:39:48 +02:00
Bartłomiej Dach
1a522a19f1 Disallow zero-length sliders from specifying a non-zero number of repeats (#35220) 2025-10-08 16:01:04 +09:00
tadatomix
e4c5fc1906 Make Graveyard panel brighter 2025-10-07 23:29:34 +03:00
Denis Titovets
568ddc2d2d Fix spinner-rpm layering 2025-10-07 19:01:50 +03:00
Denis Titovets
0ebc90462d Mute SFX when holding restart beatmap bind 2025-10-07 18:42:42 +03:00
Dean Herbert
a08f7327b1 Merge pull request #35029 from ppy/pp-dev
Q3 SR & PP release
2025-10-07 19:07:58 +09:00
Dean Herbert
533c61e719 Merge pull request #35216 from diquoks/quick-fix/mods-deselection-diff
Fix mods deselection difference
2025-10-07 18:38:40 +09:00
Dean Herbert
64fba9470d Invert conditional to read a bit better 2025-10-07 18:07:58 +09:00
Dean Herbert
743a94bd22 Re-remove duplicate error functions 2025-10-07 16:30:40 +09:00
Dean Herbert
bda5737135 Merge branch 'master' into pp-dev 2025-10-07 16:11:54 +09:00
tadatomix
03bbbebbbc Fix chevron being too bright in most cases 2025-10-07 01:21:31 +03:00
tadatomix
97c95de94a Add a test for the new group 2025-10-07 01:21:01 +03:00
tadatomix
3dcbcb5f64 Make initial work to create a ranked status style 2025-10-07 01:06:46 +03:00
Dean Herbert
19718c706a Merge pull request #35221 from bdach/adjust-tag-threshold
Adjust display tag threshold to match web
2025-10-06 20:57:26 +09:00
Bartłomiej Dach
4b3d2b0c57 Merge pull request #35222 from peppy/fix-left-hover-not-always
Fix hovering left area in song select not always activating reset action
2025-10-06 12:17:56 +02:00
Dean Herbert
df58dc0ca2 Fix hovering left area in song select not always activating reset action 2025-10-06 18:23:33 +09:00
Dean Herbert
d08b5a72b7 Add failing test scene covering song select left hover action not always activating 2025-10-06 18:21:10 +09:00
Bartłomiej Dach
b286f05c78 Adjust display tag threshold to match web
Follow-up to https://github.com/ppy/osu-web/pull/12381 - tags will glow
green starting from 5 votes rather than 10 to match the new threshold
web-side.
2025-10-06 11:09:54 +02:00
Dean Herbert
b3ed7717a8 Merge pull request #35185 from nekodex/matchmaking-more-sfx
Yet more matchmaking SFX work
2025-10-06 18:04:22 +09:00
Dean Herbert
bc7e02c30b Delay round increment sound to match animation better 2025-10-06 18:03:53 +09:00
Dean Herbert
557df19bf4 Merge pull request #35184 from bdach/song-select-reselect-group-for-selection-when-moving-left
Expand group that current selection resides in when moving mouse to left side of song select
2025-10-06 17:47:20 +09:00
Dean Herbert
8a450563fd Merge branch 'master' into song-select-reselect-group-for-selection-when-moving-left 2025-10-06 16:57:57 +09:00
Dean Herbert
f9a226d68e Merge pull request #35189 from bdach/catcher-fail-state-on-tiny-droplet-miss
Fix missing tiny droplets not changing catcher animation state to fail
2025-10-06 16:42:10 +09:00
Dean Herbert
dd846dcc52 Merge pull request #35176 from bdach/local-modified-beatmap-regressions
Fix a few issues regarding incorrect treatment of locally-modified beatmaps
2025-10-06 16:22:09 +09:00
Dean Herbert
71ae1d34cd Merge pull request #35178 from bdach/song-select-loses-selection-if-convert
Fix selection being changed on re-entering song select when a converted beatmap is selected
2025-10-06 15:53:42 +09:00
Bartłomiej Dach
dd8dfc04b3 Merge pull request #35199 from peppy/editor-timing-panel-current-row-visibility
Adjust colouring to make current row in timing visualisation more obvious
2025-10-06 08:39:05 +02:00
Dean Herbert
2f6bd6605f Update resources 2025-10-06 14:49:42 +09:00
Dean Herbert
e76d1ae619 Merge pull request #35188 from bdach/song-select-do-not-flash-wrong-leaderboard
Fix wrong leaderboard flashing briefly when quickly changing beatmaps
2025-10-06 14:43:33 +09:00
Dean Herbert
c0a7c97185 Merge pull request #35179 from bdach/song-select-scroll-closest-expanded-thing-into-view
Attempt to scroll carousel to nearest expanded panel when the current selection is filtered out
2025-10-06 14:42:54 +09:00
Denis Titovets
dbd48bc3a1 Fix mods deselection difference 2025-10-06 01:58:49 +03:00
Dean Herbert
a2770a8674 Adjust colouring to make current row in timing visualisation more obvious 2025-10-04 16:13:48 +09:00
Bartłomiej Dach
67041254db Fix missing tiny droplets not changing catcher animation state to fail
Addresses https://github.com/ppy/osu/discussions/35182.

The source as it is would have you believe that this is correct and
intentional but I'm not so sure about that. For one thing, I
cross-checked stable, and sure, missing tiny droplets does change the
state to fail over there. For another, the guard in master at

	5e642cbce7/osu.Game.Rulesets.Catch/UI/Catcher.cs (L250)

is very suspicious, given that it is dead in cause of tiny droplets
because of a preceding guard:

	5e642cbce7/osu.Game.Rulesets.Catch/UI/Catcher.cs (L227-L228)

Looking into blame, the tiny droplet guard originates at
e7f1f0f38b, wherein it looks to have been
aimed at handling *hyperdash* state specifically. Later, the logic has
been moved around and around like five times; at
7069cef9ce, the catcher animation logic
was added *below* the hyperdash-aimed guard without the comment being
updated in any way; 5a5c956ced moved the
logic from `CatcherArea` to `Catcher`, while simultaneously changing
the inline comment to no longer mention hyperdashing; and finally,
1d669cf65e added specific testing of tiny
droplets not changing catcher animation state, which I wager to be
back-engineered from the implementation as-it-was rather than
supported by any actual ground truth.

For additional reference:

- catcher animation logic in stable is at 67795dba3c/osu!/GameModes/Play/Rulesets/Fruits/RulesetFruits.cs#L411-L463
- hyperdash application logic in stable is at 67795dba3c/osu!/GameModes/Play/Rulesets/Fruits/RulesetFruits.cs#L165-L171
2025-10-03 14:52:33 +02:00
Bartłomiej Dach
68d5f53cc7 Adjust test to exercise actual desired behaviour 2025-10-03 14:39:48 +02:00
Bartłomiej Dach
a30c7f51d0 Fix wrong leaderboard flashing briefly when quickly changing beatmaps
Closes https://github.com/ppy/osu/issues/33743.
2025-10-03 12:35:58 +02:00
Jamie Taylor
4e90a2aee2 Code style fixes 2025-10-03 18:04:55 +09:00
Jamie Taylor
e24df0b9d0 Change 'quick play' button to use the 'daily' sample for now 2025-10-03 17:17:23 +09:00
Jamie Taylor
27ff2a7d49 Add SFX for round transitions 2025-10-03 17:16:45 +09:00
Jamie Taylor
3cb85f743a Rework StageDisplay SFX 2025-10-03 17:16:21 +09:00
Jamie Taylor
43878f6d33 Add SFX for enqueueing and also a looping 'waiting' SFX when in certain matchmaking states 2025-10-03 17:10:52 +09:00
Jamie Taylor
60dc2c8876 Add ducking effect on match found 2025-10-03 17:05:00 +09:00
Bartłomiej Dach
87128453d6 Expand group that current selection resides in when moving mouse to left side of song select
Closes https://github.com/ppy/osu/issues/33557.
2025-10-03 09:27:05 +02:00
Bartłomiej Dach
ae4e015352 Add failing test scene 2025-10-03 09:24:37 +02:00
Bartłomiej Dach
007de10e2b Attempt to scroll carousel to nearest expanded panel when the current selection is filtered out
Addresses https://github.com/ppy/osu/issues/33443, maybe.

I considered adding tests but they'd likely be janky and take a long
time to write, so decided against until there's a demand for it.
2025-10-02 14:28:39 +02:00
Bartłomiej Dach
3d5dc60cfe Fix selection being changed on re-entering song select when a converted beatmap is selected
Closes https://github.com/ppy/osu/issues/34062.

The root cause of the issue is that `OnEntering()` calls
`onArrivingAtScreen()`, which calls `ensureGlobalBeatmapValid()`, which
would call `checkBeatmapValidForSelection()` with a `FilterCriteria`
instance retrieved from the `FilterControl`.

The problem with that is at the time that this call chain is happening,
`FilterControl` is not yet loaded, which means in particular that it has
not bound itself to the config bindable, as that happens on
`LoadComplete()`:

	bff07010d1/osu.Game/Screens/SelectV2/FilterControl.cs (L198)

To resolve this, retrieve the bindable in `SongSelect` itself, which
ensures it is valid for reading at the time the above call chain
happens.
2025-10-02 13:27:43 +02:00
Bartłomiej Dach
e7076b9582 Add failing test case 2025-10-02 13:24:24 +02:00
Bartłomiej Dach
2a85f7b7c8 Do not attempt to retrieve score submission tokens for locally-modified beatmaps
Because it is 99% sure that doing so will fail and spam the user with
"this beatmap doesn't match the online version" notifications, and
because the map status is "locally modified", they should be pretty
aware of that already. This fixes the primary mode of the failure that
https://github.com/ppy/osu/pull/35173 was attempting to hack around.

This will have regressed somewhere around the time that BSS went live,
because at that point the editor stopped resetting online IDs for
beatmaps that got locally modified, making the `beatmapId <= 0` guards
no longer prevent attempts of submission.
2025-10-02 09:16:17 +02:00
Bartłomiej Dach
44a1a5ffc7 Add failing test coverage for no score submission attempt on known locally modified beatmap 2025-10-02 09:16:17 +02:00
Bartłomiej Dach
365cdfd40e Do not overwrite "locally modified" beatmap set status when performing online lookups in song select
A relatively recent regression. It's maybe not a huge one, in that it
probably doesn't matter all that much, but it is somewhat important to
keep the "locally modified" status of the set for as long as possible.

One reason for that is that keeping the "locally modified" status will
pull up a dialog when the user attempts to update the beatmap, warning
them that they will lose their local changes - this dialog will not show
if the online lookup flow is allowed to overwrite the map status with
something else.
2025-10-02 09:16:15 +02:00
Bartłomiej Dach
aad321a0b8 Fix clicking the osu! logo when in the multiplayer submenu opening solo play instead (#35175) 2025-10-02 16:03:46 +09:00
Dean Herbert
37a11b5592 Merge pull request #35154 from smoogipoo/matchmaking-events
Add structure and support for jumping in quick play rooms
2025-10-02 13:57:00 +09:00
Dean Herbert
565a2866ea Merge pull request #35169 from bdach/song-select-enter-on-filtered-out-carousel-should-start-map
Fix pressing Enter not starting current global beatmap if carousel is fully filtered out
2025-10-01 23:44:01 +09:00
Dean Herbert
c9a6a4887c Merge pull request #35167 from bdach/carousel-panels-consistent-update-button
Use consistent ordering of update button on carousel beatmap panels
2025-10-01 21:59:03 +09:00
Bartłomiej Dach
9b78187d29 Fix pressing Enter not starting current global beatmap if carousel is fully filtered out
Closes https://github.com/ppy/osu/issues/34693.
2025-10-01 14:31:48 +02:00
Bartłomiej Dach
0230a27de6 Add failing test case 2025-10-01 14:17:16 +02:00
Bartłomiej Dach
ccf3151172 Use consistent ordering of update button on carousel beatmap panels
Closes https://github.com/ppy/osu/issues/34810.

The reason why I touched it in this direction and not the other is only
because the standalone panel positioning of the button was touched last
in 92ed964627, thus I changed the set
panel to match that.
2025-10-01 13:49:54 +02:00
Bartłomiej Dach
b980183e98 Merge pull request #35126 from Joehuu/song-select-silver-terminology
Use silver S/SS terminology when grouping by rank/grade in song select
2025-10-01 13:30:50 +02:00
Jinkku
b7d36cffd4 Refactor spritesheet-based icons to be single-file based (#34976)
Co-authored-by: Bartłomiej Dach <dach.bartlomiej@gmail.com>
Co-authored-by: Dean Herbert <pe@ppy.sh>
2025-10-01 13:30:06 +02:00
Dean Herbert
345ae29770 Merge pull request #35163 from bdach/carousel-stop-re-expanding-group
Do not forcibly re-expand carousel groups on refilters if the user manually collapsed them
2025-10-01 16:33:59 +09:00
Bartłomiej Dach
6bf589af88 Use slightly safer method of converting to string 2025-10-01 08:50:39 +02:00
Bartłomiej Dach
03455ba683 Merge branch 'master' into song-select-silver-terminology 2025-10-01 08:32:07 +02:00
Dean Herbert
1f7aa2fe5e Merge pull request #35158 from bdach/fix-carousel-teleports-on-delete
Fix song select carousel sometimes teleporting on beatmap set deletion
2025-10-01 15:16:26 +09:00
Dean Herbert
e26e44cd45 Merge pull request #35159 from bdach/carousel-target-vertical-center
Add half-height-of-selected-panel adjustment to carousel scroll target
2025-10-01 15:00:14 +09:00
Dean Herbert
5ab40e4b73 Merge pull request #35160 from bdach/carousel-not-switching-beatmap-on-ruleset-change
Fix song select not changing global beatmap correctly when switching rulesets
2025-10-01 13:47:08 +09:00
Joseph Madamba
ee638492bf Remove now unused description attributes from ScoreRank 2025-09-30 10:55:47 -07:00
Joseph Madamba
7d81ff8115 Fix jank GetRankLetter() method 2025-09-30 10:52:55 -07:00
Joseph Madamba
114e7f5c61 Rename GetRankName to GetRankLetter 2025-09-30 10:50:34 -07:00
Dean Herbert
e2b9288716 Merge pull request #35161 from bdach/song-select-refetch-online-on-reenter
Forcibly refetch online beatmap content on re-entering song select
2025-09-30 23:20:38 +09:00
Dean Herbert
919c47b918 Merge pull request #35111 from smoogipoo/quick-play-keybinds
Add keybinds to matchmaking queue screen
2025-09-30 23:19:33 +09:00
Bartłomiej Dach
bc4d5d07d7 Do not forcibly re-expand carousel groups on refilters if the user manually collapsed them
RFC. Closes https://github.com/ppy/osu/issues/35091.
2025-09-30 15:24:48 +02:00
Bartłomiej Dach
4b2f3efcbd Add tests covering desired UX 2025-09-30 15:24:35 +02:00
Bartłomiej Dach
33ddb84633 Forcibly refetch online beatmap content on re-entering song select
Closes https://github.com/ppy/osu/issues/34546.
2025-09-30 13:58:10 +02:00
Bartłomiej Dach
f66c8d10e0 Fix song select not changing global beatmap correctly when switching rulesets
Closes https://github.com/ppy/osu/issues/35113.

Regressed in dfed564bda - setting
`Carousel.CurrentSelection` was not all that
`requestRecommendedSelection()` was doing there...

A potential point of discussion is whether this global beatmap switch
should be debounced or instant. I'm not sure I have a particularly
well-formed opinion on that. One argument in favour of not debouncing is
that if you look closely at the left side of the screen while the
debounce is in progress, you can still sort of see the broken behaviour
happen - it just doesn't stay there forever.

Thankfully `ensureGlobalBeatmapValid()` being called in every scenario
on screen suspension prevented this bug from being any worse than it is
right now.
2025-09-30 13:34:26 +02:00
Bartłomiej Dach
38278eaaac Merge pull request #35157 from peppy/update-framework
Update framework
2025-09-30 13:25:39 +02:00
Bartłomiej Dach
ac2df49c35 Demonstrate failure in test 2025-09-30 13:19:03 +02:00
Bartłomiej Dach
2edd49d2c0 Add half-height-of-selected-panel adjustment to carousel scroll target
Intended to address https://github.com/ppy/osu/issues/35147, maybe?

The old carousel would target the vertical center of the active panel
when scrolling:

	b9e1b6969e/osu.Game/Screens/Select/BeatmapCarousel.cs (L948)

This was not in place in the new carousel, weirdly, which was targeting
the top-left corner of the selected panel.
2025-09-30 12:57:24 +02:00
Dean Herbert
a078d4bdb4 Merge pull request #35149 from smoogipoo/qp-fix-nullref
Fix nullref when users leave quick-play rooms
2025-09-30 19:23:05 +09:00
Dean Herbert
6e39e714e1 Refactor to simplify sample handling 2025-09-30 19:22:16 +09:00
Dean Herbert
d512adb971 Merge branch 'master' into quick-play-keybinds 2025-09-30 19:07:29 +09:00
Bartłomiej Dach
a3e09a1c31 Fix song select carousel sometimes teleporting on beatmap set deletion
Closes https://github.com/ppy/osu/issues/35010.

The issue here does not reproduce consistently, and is more or less
random in presentation. That said, using a large enough realm database
more or less ensures that the issue will present itself (in testing on a
large realm db, the failure rate is around ~50%).

This actually regressed in https://github.com/ppy/osu/pull/34842. The
core failure in this case is here:

	fd412618db/osu.Game/Screens/SelectV2/BeatmapCarousel.cs (L161)

The `CheckModelEquality()` call above is comparing two `BeatmapInfo`s,
but a84c364e44 changed the
`BeatmapInfo`-comparing path of `CheckModelEquality()` to use
`GroupedBeatmap` instead. Due to this, `CheckModelEquality()` falls back
to reference equality comparison for `BeatmapInfo`s. When that reference
comparison fails, the carousel stops detecting that the current
selection was deleted from under it correctly, and therefore the
proximity-based selection logic never runs.

Due to the human-obvious mechanism of failure and relatively easy
manual reproduction I've decided not to try and add tests for this,
as they are likely to take a long time to write due to the mechanism
of failure being incorrect use of reference equality specifically. That
said, I can try on request.
2025-09-30 11:41:47 +02:00
Dan Balasescu
e636a09e0f Add ability to jump in quick play 2025-09-30 18:38:30 +09:00
Dan Balasescu
3ebb72a20a Add avatar action request/event models 2025-09-30 18:32:04 +09:00
Dean Herbert
256483165f Update framework 2025-09-30 18:10:34 +09:00
Dean Herbert
1a084f28ff Merge pull request #35056 from OliBomby/anchor-snap
Allow snapping slider control points to nearby objects in the editor
2025-09-30 18:01:02 +09:00
Dean Herbert
a0ff7728b9 Merge pull request #35057 from OliBomby/snap-to-anchor
Allow snapping to nearby visible slider control points in the editor
2025-09-30 18:00:31 +09:00
Dean Herbert
9860bba2f3 Merge branch 'master' into qp-fix-nullref 2025-09-30 17:46:53 +09:00
Dean Herbert
bb9b4a2827 Merge pull request #35151 from bdach/maybe-fix-bss-clicking
Ensure submission progress sample is stopped when transitioning into a final state
2025-09-30 17:29:21 +09:00
Bartłomiej Dach
057406c910 Simplify logic further 2025-09-30 08:32:53 +02:00
Bartłomiej Dach
062174d874 Merge pull request #35049 from AeroKoder/fix-scaling-sliders
Fix certain sliders incorrectly registering as a horizontal/vertical only slider.
2025-09-30 15:17:05 +09:00
Dan Balasescu
d31df5bbc7 Make quick play chat not hold focus 2025-09-30 12:35:33 +09:00
AeroKoder
41698d5848 Updated moveSelectionInBounds in OsuSelectionScaleHandler to match the one in OsuSelectionHandler 2025-09-29 09:27:01 -07:00
Dean Herbert
838b8d3013 Merge pull request #35145 from tadatomix/song-select/coloured-group-ranks
Colour Rank Achieved panels to the related rank
2025-09-29 23:40:28 +09:00
Bartłomiej Dach
2c39e1e9db Ensure submission progress sample is stopped when transitioning into a final state
Probably closes https://github.com/ppy/osu/issues/35138. I'm not sure. I
only got the issue to reproduce once, on dev, using a very large
archive that was uploading really slowly, and then never again.

The working theory is that basically handling of `progressSampleChannel`
is quite dodgy and it could possibly, in circumstances unknown, be
allowed to play forevermore after transitioning to failed / canceled
state. Success state does not get this treatment because it has special
logic to set progress to 1.
2025-09-29 15:30:45 +02:00
OliBomby
18549ea7dc Remove all linq calls from getScreenSpaceControlPointNodes 2025-09-29 15:07:03 +02:00
OliBomby
d76dce76ec dont snap inherited bspline type control points to nearby objects 2025-09-29 14:44:12 +02:00
OliBomby
40cbe58220 Revert inline method for code abstraction 2025-09-29 14:23:19 +02:00
Bartłomiej Dach
68523a637a Merge pull request #35128 from peppy/matchmaking-design-work
Switch to using more standardised beatmap cards in quick play
2025-09-29 20:15:10 +09:00
Dean Herbert
3f5b71fdc3 Always explicitly assign the action for beatmap cards 2025-09-29 17:31:45 +09:00
Dan Balasescu
573d639238 Fix nullref when users leave quick-play rooms 2025-09-29 16:56:48 +09:00
Dan Balasescu
82beeec730 Add failing test 2025-09-29 16:55:54 +09:00
Bartłomiej Dach
5336bcd71d Merge pull request #34934 from Valerus9/fixtestdouble
Fix TestDouble failing on systems where the decimal separator isn't a dot
2025-09-29 16:46:32 +09:00
Bartłomiej Dach
47b6b70cae Avoid duplicating rank name colour constants 2025-09-29 09:44:05 +02:00
Bartłomiej Dach
fd412618db Adjust initial pool size for group rank displays
11 is excessive. There can ever be at most 9 of these panels, ever,
because there are at most 9 possible letter grades at this time (F, D,
C, B, A, S, S+, SS, SS+).
2025-09-29 09:42:36 +02:00
Bartłomiej Dach
9dc79e6f0d Avoid passing the same thing three times 2025-09-29 09:42:33 +02:00
Bartłomiej Dach
ba3d7392bf Merge pull request #35120 from LumpBloom7/fix-toolbox-tooltip
Fix composition tool tooltip not changing text when enabled
2025-09-29 16:04:15 +09:00
Bartłomiej Dach
bed9fb0ba6 Merge pull request #35044 from Joehuu/old-badge-alignment
Match profile badge centre alignment with web
2025-09-29 15:57:47 +09:00
Bartłomiej Dach
9257e62bac Merge branch 'master' into fixtestdouble 2025-09-29 08:43:49 +02:00
Bartłomiej Dach
70f683e7fa Use slightly nicer way of invarianting rate adjust setting value 2025-09-29 08:39:01 +02:00
Bartłomiej Dach
d1cf248b9a Remove redundant double string invariance 2025-09-29 08:33:18 +02:00
Bartłomiej Dach
295adf9f28 Add actual test coverage for relevant failure 2025-09-29 08:32:09 +02:00
tadatomix
6ac4f482ee Add a new test for Rank Achieved panels 2025-09-28 23:10:32 +03:00
tadatomix
33791318fe Call a new panel style, when Rank Achieved grouping is picked 2025-09-28 22:56:32 +03:00
tadatomix
cf20bbbf80 Add a new Rank Achieved panel to BeatmapCarousel.cs 2025-09-28 22:55:22 +03:00
tadatomix
55ef221390 Add a separate panel for RankAchieved group 2025-09-28 22:49:45 +03:00
Dean Herbert
0f8d8780d3 Adjust stage display animation to linger for longer 2025-09-26 18:26:11 +09:00
Dean Herbert
b743506207 Keep panel backgrounds loaded 2025-09-26 18:26:11 +09:00
Dean Herbert
0a17a3c4ed Use BeatmapCard for matchmaking beatmap display 2025-09-26 18:09:05 +09:00
Dean Herbert
da80b61f38 Allow visibly disabling the "go to beatmap" button
Easiest way to make this work without rewriting the layout logic.

I think it makes sense to have the button still exist there but not be
usable on certain screens.
2025-09-26 18:08:47 +09:00
Dean Herbert
badeb24d56 Change beatmap in selection panels to always be non-null 2025-09-26 16:53:22 +09:00
Dean Herbert
76c3043913 Improve selection animation border isolation
I'm not final on the design, just wanted to split it out into an actual
border element rather than an "underlay".
2025-09-26 16:48:56 +09:00
Derrick Timmermans
c66cc5ebb1 Handle disabled text in composition tool button 2025-09-26 09:28:04 +02:00
Dean Herbert
9dc5605d95 Scale active stage larger 2025-09-26 16:15:37 +09:00
Dean Herbert
3af4edf051 Remove pointless TestSceneMatchmakingScreenStack
Should always be using `TestSceneMatchmakingScreen` right?
2025-09-26 15:48:29 +09:00
Dean Herbert
e9063dcf57 Simplify flash layer 2025-09-26 15:48:29 +09:00
Dean Herbert
d690477776 Add context menu to show beatmap details 2025-09-26 14:52:23 +09:00
Dean Herbert
7879e091c0 Simplify avatar handling in beatmap selection panels 2025-09-26 14:39:20 +09:00
Dean Herbert
702511c918 More matchmaking renames and spacing adjusts 2025-09-26 14:22:55 +09:00
Dean Herbert
e1ba1b45b0 Adjust matchmaking naming, namespaces, xmldoc (#35123)
* Adjust matchmaking naming, namespaces, xmldoc

* Change partial filenames to use `.` instead of `_` separator
2025-09-26 12:17:28 +09:00
Joseph Madamba
8ea9e2e4bb Change non-localisable sh/xh to correct terminology 2025-09-25 15:45:12 -07:00
Joseph Madamba
ef88a3530a Use silver S/SS terminology when grouping by rank/grade in song select 2025-09-25 14:18:41 -07:00
Derrick Timmermans
df795da070 Fix composition tool tooltip not changing text when enabled 2025-09-25 10:44:05 +02:00
Dean Herbert
12e29f0bcc Attempt to fix flaky TestGameplaySettingsDoesNotExpandWhenSkinOverlayPresent
See https://github.com/ppy/osu/pull/35118/checks?check_run_id=51202910556.
2025-09-25 15:56:48 +09:00
Dean Herbert
ff54908687 Matchmaking stage display / screen layout design improvements (#35118) 2025-09-25 15:16:22 +09:00
Dan Balasescu
3c37fb11be Add sounds 2025-09-24 19:47:13 +09:00
Dan Balasescu
3176006510 Add select keybind to queue screen buttons 2025-09-24 19:46:54 +09:00
Dan Balasescu
183ccbf792 Add left/right keybinds to pool selector 2025-09-24 19:46:54 +09:00
Dean Herbert
f1ffc1dc33 Merge pull request #35109 from smoogipoo/matchmaking-fix-chat
Fix matchmaking chat not working
2025-09-24 18:59:24 +09:00
Dan Balasescu
b3cfded8f2 Fix matchmaking chat not working 2025-09-24 18:58:01 +09:00
Dean Herbert
67291c1a42 Fix match-found not playing due to incorrect case in path 2025-09-24 18:55:02 +09:00
Dean Herbert
93afc83c4b Merge pull request #35105 from smoogipoo/improve-mm-testing
Various improvements to matchmaking testability
2025-09-24 16:26:43 +09:00
Andrei Zavatski
57e5fe7265 Improve FailRetryDisplay performance (#35101) 2025-09-24 15:53:16 +09:00
Dean Herbert
e082d24bab Merge pull request #35106 from smoogipoo/matchmaking-fix-player-list
Fix players positioning on next matchmaking round
2025-09-24 15:38:17 +09:00
Dan Balasescu
13cb5deca3 Fix players positioning on next matchmaking round 2025-09-24 14:44:27 +09:00
Dan Balasescu
3556d6c8c8 Reduce number of picks shown 2025-09-24 14:28:04 +09:00
Dan Balasescu
83dafb4ef9 Fix incorrect default room types 2025-09-24 14:28:04 +09:00
Dan Balasescu
9df3bd9a98 Add stage-changing helper, use in test scenes 2025-09-24 14:28:01 +09:00
Dan Balasescu
597a06ac38 Set initial matchmaking room state 2025-09-24 13:42:55 +09:00
Dean Herbert
a129345a82 Merge pull request #35098 from smoogipoo/fix-intermittent-test
Attempt to fix intermittent tests
2025-09-24 03:00:31 +09:00
Dean Herbert
8660578d2c Merge pull request #35053 from smoogipoo/matchmaking-player-list-modes
Add display styles to matchmaking player list
2025-09-24 02:59:57 +09:00
Dean Herbert
bf63b6f9f0 Simplify lookup method to appease inspection
See https://github.com/ppy/osu/pull/35100/files.
2025-09-23 22:55:43 +09:00
Dean Herbert
85ea278e1d Merge pull request #35100 from smoogipoo/matchmaking-match-start-sample
Play gameplay start sample in matchmaking
2025-09-23 22:54:31 +09:00
Dan Balasescu
37e661b27e Fix intermittent collection dropdown tests 2025-09-23 18:23:41 +09:00
Dan Balasescu
ff6c6083b4 Mark spinner rewind test as flaky 2025-09-23 18:18:07 +09:00
Dan Balasescu
aba0d2c1d3 Play gameplay start sample in matchmaking 2025-09-23 17:52:33 +09:00
Dan Balasescu
3789010dd5 Attempt to fix intermittent test 2025-09-23 15:09:45 +09:00
Dan Balasescu
82ac42cae3 Replace nested ternaries with ifs 2025-09-23 13:45:10 +09:00
Dean Herbert
87061959ea Fix failing screen test 2025-09-23 12:49:34 +09:00
Dean Herbert
1840363713 Add one more temoprary workaround for rider failings 2025-09-21 13:09:27 +09:00
qinvvv
b91ff8a5c5 Fix osu!mania legacy skin WidthForNoteHeightScale not being used (#35050)
* Add osu!mania legacy skin widthForNoteHeightScale

* Ensure WidthForNoteHeightScale correctly defaults to MinimumColumnWidth
2025-09-19 10:53:25 +09:00
Olivier Schipper
9fddce92e9 Reword inline comments 2025-09-19 01:22:35 +02:00
Olivier Schipper
7c5278ea45 Fix incorrect skip count 2025-09-19 01:09:42 +02:00
Olivier Schipper
0e523d3eb7 Allow snapping to visible slider control points 2025-09-19 00:30:31 +02:00
Olivier Schipper
55a4c75e76 Allow slider control points to snap to nearby objects
and a bit of code cleanup to reduce code duplication with the slider head anchor snapping
2025-09-18 21:26:49 +02:00
Dean Herbert
c07dd72f6d Merge pull request #35037 from bdach/totp
Add client-side support for TOTP authentication
2025-09-18 23:50:07 +09:00
Dean Herbert
c320c9df1c Merge branch 'master' into totp 2025-09-18 22:53:20 +09:00
Dan Balasescu
c08d88eb7f Adjust namespaces 2025-09-18 16:28:25 +09:00
Dan Balasescu
5eaf376a60 Decrease scale of panels 2025-09-18 16:28:25 +09:00
Dan Balasescu
b26a1b6330 Add display style to PlayerPanelList 2025-09-18 16:28:25 +09:00
AeroKoder
6dc3432735 Fix certain slider shapes incorrectly registering as a horizontal/vertical only slider. 2025-09-17 15:34:18 -07:00
Joseph Madamba
e0c86b3048 Match profile badge centre alignment with web 2025-09-16 20:37:44 -07:00
Givy120
7852df639a Use DeltaTime in RhythmEvaluator to increase stability (#32790)
* Update RhythmEvaluator.cs

* Rename `StrainTime` into `AdjustedDeltaTime`

---------

Co-authored-by: StanR <hi@stanr.info>
2025-09-16 13:31:14 +00:00
Bartłomiej Dach
37f58e5c80 Add client-side support for TOTP authentication
Closes https://github.com/ppy/osu/issues/34972.
2025-09-16 10:36:34 +09:00
Dean Herbert
0dbee70ca9 Merge pull request #35022 from smoogipoo/matchmaking-fix-errors
Fix errors in gameplay stage of matchmaking
2025-09-15 22:25:58 +09:00
James Wilson
2749184c38 Remove databasing of MechanicalDifficulty and ReadingDifficulty attributes (#35028)
* Remove databasing of `MechanicalDifficulty` and `ReadingDifficulty` attributes

* Update attribute IDs
2025-09-15 13:46:00 +03:00
James Wilson
0a3844d3ef Update tests (#35026) 2025-09-15 12:46:27 +03:00
Eloise
c7f50f35b7 osu!taiko final balancing before deploy (#34962)
* Change maximum UR estimation + buff rhythm

* Penalty for classic ezhd

* Buff mono bonus to counterbalance logic fix

* New miss penalty + slightly nerf length bonus

* Adjust rhythm values

* Adjust penalty and buff high SR acc

* Exclude HDFL from hidden reading penalties

* Make comment a lil nicer

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-09-15 09:43:26 +01:00
Dan Balasescu
9577472c9e Fix errors in gameplay stage of matchmaking 2025-09-15 12:49:42 +09:00
Dean Herbert
55fe927af0 Merge pull request #34985 from nekodex/matchmaking-sfx
More matchmaking SFX work
2025-09-14 14:38:10 +09:00
Dean Herbert
4ccfebe842 Update resources 2025-09-14 14:15:16 +09:00
Dean Herbert
b7d6e76d19 Merge pull request #35000 from bdach/lazer-judgement-counter-misalign 2025-09-13 22:29:58 +09:00
Valerus9
e34b0659da Fix testtooltip failure 2025-09-13 06:44:29 +02:00
Bartłomiej Dach
e73e9275ba Fix argon judgement counter looking misaligned with wireframe off
Closes https://github.com/ppy/osu/issues/34959.

`ArgonCounterTextComponent` is pretty terrible and prevents being able
to fix the issue easily. The core issue is that this is the first
instance of the component's usage where the label text can be longer
than the counter in the X dimension, so the total width of any counter
is equal to max(label width, counter width), and the label will be
aligned to the left of that width, while the counter will be aligned to
the right of that width.

The fix sort of relies on the fact that I don't expect *any* consumer of
`ArgonCounterTextComponent` that meaningfully uses the wireframe digits
to want the non-wireframe digits to be aligned to the *left* rather than
the right. It's not what I'd expect any segmented display to work.
(There are usages that specify `TopLeft` anchor, but they usually
display the same number of wireframe and non-wireframe digits, so for
them it doesn't really matter if the digits are left-aligned to the
wireframes or not.)
2025-09-13 11:11:45 +09:00
Bartłomiej Dach
a9c021ce04 Demonstrate failure in test 2025-09-13 10:50:33 +09:00
Jamie Taylor
9a2513230c Add SFX for stage progression feedback 2025-09-12 18:27:16 +09:00
Jamie Taylor
ccc5ca5d80 Rework matchmaking cloud SFX 2025-09-12 18:25:08 +09:00
Jamie Taylor
9b9e7a8f75 Refactor selection roulette SFX logic 2025-09-12 18:23:02 +09:00
Dean Herbert
5b781655c1 Merge pull request #34815 from smoogipoo/matchmaking
Add matchmaking
2025-09-11 18:17:10 +09:00
Dean Herbert
644b797734 Add temporary workaround for rider bug (attempt 2) 2025-09-11 16:21:55 +09:00
Dean Herbert
e5fbf62ff5 Revert "Add temporary workaround for rider bug"
This reverts commit bcff6be5f6.
2025-09-11 16:21:40 +09:00
Dean Herbert
bcff6be5f6 Add temporary workaround for rider bug 2025-09-11 16:21:03 +09:00
Dean Herbert
0c68a91b4c Fix main menu key tests 2025-09-11 16:09:17 +09:00
Dean Herbert
69a0ac6c76 For tachyon release 2025-09-11 16:00:41 +09:00
Dean Herbert
619a6e7321 Merge branch 'master' into matchmaking 2025-09-11 15:11:01 +09:00
Dan Balasescu
a436372b05 Allow exit during matchmaking intro (#137) 2025-09-09 18:09:43 +09:00
Dan Balasescu
449038d070 Split main menu buttons into multiplayer section (#136)
Co-authored-by: Dean Herbert <pe@ppy.sh>
2025-09-08 13:54:06 +09:00
Dan Balasescu
ffab8342f2 Merge branch 'master' into pp-dev 2025-09-07 19:22:08 +09:00
Dan Balasescu
31188127ef Transmit availability with ready state
Regressed with https://github.com/ppy/osu-server-spectator/pull/311.

As it turns out, the method not just resets ready states but also the
beatmap availabilities. So we have to send it again here.

I have a feeling this is also broken in standard multiplayer in some way
or another.
2025-09-07 16:17:28 +09:00
Dan Balasescu
48bad31255 Pessimistically set the beatmap in all stages (#135) 2025-09-07 16:17:28 +09:00
Dan Balasescu
1a49c81bab Fix matchmaking being permanently sound-ducked (#134) 2025-09-07 16:17:28 +09:00
Dan Balasescu
27db49bad3 Fix results screen crash from missing users (#133) 2025-09-07 16:17:28 +09:00
Jamie Taylor
e6dbb1020c Add some audio feedback to the matchmaking flow (#132) 2025-09-07 16:17:28 +09:00
Dan Balasescu
e0c11504a2 Query for available pools for selection (#131) 2025-09-07 16:17:27 +09:00
Dan Balasescu
35e1fa6660 Fix test failure 2025-09-07 16:17:27 +09:00
Dan Balasescu
330b61f4c0 Add ruleset selector (#130) 2025-09-07 16:17:27 +09:00
Dan Balasescu
3985596602 Join matchmaking rooms with password 2025-09-07 16:17:27 +09:00
Dan Balasescu
3786efaa5e Separate IMatchmakingClient and IMultiplayerClient
Co-authored-by: Dean Herbert <pe@ppy.sh>
2025-09-07 16:17:27 +09:00
Dan Balasescu
0225c1a867 Fix event handler leak 2025-09-07 16:17:27 +09:00
Dan Balasescu
111b98ef8e Add matchmaking 2025-09-07 16:17:27 +09:00
Valerus9
22bfab95b0 Fix testdouble failure. 2025-09-06 06:43:13 +02:00
James Wilson
a78c78ecdd Update difficulty calculation tests for osu ruleset (#34828) 2025-09-02 13:19:34 +03:00
StanR
84309f57c5 Reduce rhythm difficulty if current object is doubletappable (#34877)
* Reduce rhythm difficulty if current object is doubletappable

* Buff rhythm multiplier
2025-09-02 10:22:12 +01:00
James Wilson
6a35b7237b Prevent Taiko difficulty crash if a map only contains 0-strains (#34829)
* Prevent Taiko difficulty crash if a map only contains 0-strains

* Add second check for safety

This is accessing a different array of strains. I'd rather be safe than sorry.

* Add guard in PP too

* Make `MarginOfError` a const
2025-08-31 12:04:56 +00:00
James Wilson
90ac249f5e Move SpunOut penalty back to PP (#34838)
This isn't a super common mod compared to every other one on the list, it's probably not worth the storage (and memory in case of stable) implications. We can look at revisiting this once we have actual spinner difficulty considerations
2025-08-31 12:32:28 +05:00
Jay Lawton
087f0565e6 Implement deltatimenormaliser into rhythm grouping logic (#33403)
* additions

* review fixes

* Formatting

* comments + review

* fix

* fix renaming and namespace

* balancing + round

---------

Co-authored-by: tsunyoku <tsunyoku@gmail.com>
Co-authored-by: StanR <hi@stanr.info>
2025-08-28 13:19:13 +00:00
Givikap120
dce4132209 Nerf Low AR HD bonus for slideraim (#34215)
* Refactor slider factor calculation

* Nerf low AR HD bonus for slideraim

* finish merge

* Fixes

* Fix comment

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-08-09 19:40:37 +00:00
James Wilson
fa1fea02dc Fix edge case that estimates sliderbreaks in impossible scenarios (#34544)
* Test theory crafting

* Place in more appropriate place

* fix a bit better

* Move things around

* Reduce diff

---------

Co-authored-by: StanR <hi@stanr.info>
2025-08-07 19:13:00 +01:00
StanR
802e559472 Add DF flashlight rating reduction (#34081)
* Add DF flashlight rating reduction

* Use reverse lerp
2025-08-06 17:10:00 +01:00
Eloise
dbb16fc834 osu!taiko reduce multiplier for hidden on lazer (#34089)
* Reduce multiplier for hidden on lazer

* Refactor

* Quality

* The space
2025-07-30 21:48:45 +02:00
Eloise
eaaca60b1d osu!taiko new acc pp formula + rhythm difficulty penalty (#34188)
* New acc curve

* Penalise rhythm difficulty based on unstable rate

* Rename mono acc stuff for more clarity

* Fix nullable

* Rename stuff

* Get actual estimation for SS unstable rate

* Double space my bad

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-07-29 18:03:13 +00:00
Eloise
803e30f50f osu!taiko consistency factor changes using object strains (#34327)
* Calculate consistency factor from object strains

* Use `totalDifficultHits` in performance calc

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-07-28 13:58:54 +00:00
Jay Lawton
e54779ceee Fix colour penalties being bypassed via repeated ratio variance (#33641)
* fix a lil bit of colour

* review comments

* fix empty initialiser
2025-07-27 14:31:46 +00:00
StanR
83765abe34 Make visibility-based bonuses be additive to ratingMultiplier instead of multiplicative (#34367)
* Make visibility-based bonuses be additive to `ratingMultiplier` instead of multiplicative

* Slightly buff low AR HD, slightly nerf low AR TC
2025-07-25 19:51:30 +00:00
James Wilson
28d36dd3bd Move rating calculations to OsuRatingCalculator (#33265)
* Move rating calculations to `OsuRatingCalculator`

* Use `CalculateDifficultyRating`
2025-07-25 18:47:21 +03:00
James Wilson
945db7b431 Fix backwards logic on visibility bonus (#34369)
Co-authored-by: StanR <hi@stanr.info>
2025-07-25 06:50:23 +00:00
Givikap120
56b072cfd9 remove high CS bonus from slider bonus (#34214)
Co-authored-by: StanR <hi@stanr.info>
2025-07-24 15:41:36 +00:00
Jay Lawton
ddf9d6b8c8 ensure monolengthbonus applies to new strain contribution only (#33635)
* stamina fix

* review changes

* fix naming

---------

Co-authored-by: StanR <hi@stanr.info>
2025-07-22 09:37:51 +00:00
Givikap120
a75e0c3850 Refactor AR and OD calculations in osu! pp calculation (#34065)
* Add AR and OD calculation functions

* use created functions in perfcalc
2025-07-08 18:37:41 +03:00
Dan Balasescu
f2dcf2024a Merge branch 'master' into pp-dev 2025-07-08 20:58:22 +09:00
Dean Herbert
7ec6c8bf57 Merge branch 'master' into pp-dev 2025-07-07 17:56:44 +09:00
Natelytle
cf4d6bea72 Implement difficulty evaluators in the osu! mania ruleset (#33411)
* stuff

* Implement evaluators

* Typo

* Fixes

* clarifying comment

* Fix CalculateInitialStrain

* Remove debug line

* Small code quality fix

* Address comments, slight code quality fixes

* Change comment for clarity

---------

Co-authored-by: StanR <hi@stanr.info>
2025-06-27 23:46:52 +01:00
Natelytle
d5ef8c8524 Replace error functions in DifficultyCalculationUtils with good-enough approximations (#33717)
* Reimplement error functions

* Fix bug with adjustment for negative values

* Formatting

---------

Co-authored-by: tsunyoku <tsunyoku@gmail.com>
2025-06-18 13:14:01 +00:00
Givikap120
b783bb70e9 Optimize rhythm evaluation by replacing curve (#33423)
* Update RhythmEvaluator.cs

* add smoothstep bell curve

* Update osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs

Co-authored-by: StanR <castl@inbox.ru>

* Update osu.Game.Rulesets.Osu/Difficulty/Evaluators/RhythmEvaluator.cs

Co-authored-by: StanR <castl@inbox.ru>

* Rename variables

---------

Co-authored-by: StanR <castl@inbox.ru>
Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-13 13:02:31 +05:00
James Wilson
19e9bffc11 Q2 osu! PP rebalance (#33640)
* Rebalance aim and speed

* Rebalance star rating

* Attempt further speed balancing

* More balancing

* More balancing

* Buff aim a bit

* More speed balancing

* Global rebalance

* Speed balancing

* Global rebalancing

* More speed balancing

* Buff aim

* MORE BALANCING

* Revert "Rebalance star rating"

This reverts commit f48c7445e12174c65b74edfef863cb3ae3cc29ff.
2025-06-11 19:46:46 +03:00
StanR
87023b22ea Remove wide/wiggle angle bonus rhythm requirements (#31409)
* Remove aim angle bonuses angle restrictions

* Remove unrelated change

* Only apply acute bonus for similar rhythms

* Cleanup

* Fix incorrect multiplication order

* Remove unrelated wide bonus change

* Remove redundant check

* Award less wide/wiggle bonus for sliders

* Balancing

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-10 11:20:55 +00:00
Eloise
7066f3def7 osu!taiko changes to length bonus using consistency factor (#33582)
* Implement new formulas for length bonus

* Add comment(s)

* Fix up HDFL thing
2025-06-10 11:31:11 +01:00
StanR
699fbb1a85 Decouple velocity change bonus from wide angle bonus (#33541)
* Decouple velocity change bonus from wide angle bonus

* Replace sin with smoothstep

* Set multiplier back to 0.75

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-09 11:02:49 +00:00
Eloise
5df41c08f4 osu!taiko new miss penalty using consistency factor (#33409)
* New formulas for effective miss count and penalty

* More elaborate comments

* More comment stuff
2025-06-08 22:47:26 +00:00
Natelytle
c4b07413b1 Refactor and re-comment osu! standard deviation calculations (#33218)
* Refactor

* Fix typo

* Prevent double.PositiveInfinity from occuring

* Fix leftover code branch

* Fix some idiot putting Math.Max instead of Math.Min

* Address NaN values

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-08 22:41:13 +00:00
Eloise
6ae8a68389 osu!taiko simplify pp summing and make performance attributes accurate (#33500)
* Change pp summing and adjust multipliers

* Add back convert consideration for hidden

* And the other one whoops

---------

Co-authored-by: StanR <hi@stanr.info>
2025-06-08 08:48:28 +00:00
Wulpey
642b938358 Reduce combo scaling for osu!catch (#33417)
* Reduce combo scaling for osu!catch

This is a conservative reduction, a middle point between the current
scaling and the CSR proposals.

* Reduce osu!catch combo scaling further

0.45 makes little difference so let's reduce it a bit more.

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-07 14:17:46 +00:00
Eloise
6a9aeda5d4 Remove multipliers nerfing ez (#33415) 2025-06-06 16:46:33 +03:00
Eloise
b982c3cd20 Remove stamina skill buff from strain length bonus (#33380)
Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-03 14:47:42 +01:00
Givikap120
366f2469ef Fix incorrect limit for sliderbreak estimation (#33110)
* fix incorrect clamp

* Add inline comment to explain `possibleBreaks` calculation

* move limit to aim and speed functions

* fix negative okMehAdjustment

* fix cases where lazer effective misscount gets reduced

* Simplify scope of changes

* Correct variable name

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-06-03 09:29:16 +01:00
Bartłomiej Dach
737ec8b2a8 Merge branch 'master' into pp-dev 2025-05-31 19:55:56 +02:00
Givikap120
63654ad1e0 Replace HD acc scaling adjust with reverse lerp util (#33271) 2025-05-28 13:59:23 +01:00
Givikap120
01d9c526d9 Rebalance HD bonus (#33237)
* initial commit

* changed HD curve

* removed AR variable

* update for new rework

* nerf HD acc bonus for AR>10

* add another HD nerf for AR>10

* Update OsuDifficultyCalculator.cs

* fix speed part being missing

* Update OsuDifficultyCalculator.cs

* rework to difficulty-based high AR nerf

* move TC back to perfcalc

* fix nvicka

* fix comment

* use utils function instead of manual one

* Clean up

* Use "visibility" term instead

* Store `mechanicalDifficultyRating` field

* Rename `isFullyHidden` to `isAlwaysPartiallyVisible` and clarify intent

* Remove redundant comment

* Add `calculateDifficultyRating` method

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-05-26 13:16:48 +03:00
Jay Lawton
ace74824b8 Add a consistency factor to osu!taiko diffcalc (#33233)
* add consistency attribute

* write attributes to json for serialisation

* comment change

* fix json, add mechanical difficulty

* write new attributes to database

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-05-23 11:57:37 +00:00
Givikap120
ee055ba8f5 Add spinners support to combo based estimated misscount (#33170)
* add spinner support

* Make `CalculateSpinnerScore` private & clarify comments

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-05-22 22:27:16 +00:00
StanR
60eaf088df Buff precision difficulty rating in osu! (#28877)
* Buff precision difficulty rating in osu!

* Fix position repetition calculation

* Fix aim evaluator crashing, move small circle bonus calculation, adjust the curve slightly

* Refactor

* Fix code quality

* Semicolon

* Apply small circle bonus to speed too

* Fix formatting

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-05-22 09:11:51 +01:00
James Wilson
4a343ceaf1 Move Ruleset and DifficultyCalculator allocations to global setup (#33220) 2025-05-21 11:25:20 +00:00
James Wilson
553a8601ed Add AimEstimatedSliderBreaks and SpeedEstimatedSliderBreaks performance attributes (#33181) 2025-05-18 12:50:29 +00:00
Givikap120
9314ea94b5 Change effective misscount to be based on legacy score and combo at the same time (#33066)
* implement stuff

* fix basic issues

* rework calculations

* sanity check

* don't use score based misscount if no scorev1 present

* Update OsuPerformanceCalculator.cs

* update misscount diff attribute names

* add raw score misscount attribute

* introduce more reasonable high bound for misscount

* code quality changes

* Fix osu!catch SR buzz slider detection (#32412)

* Use `normalized_hitobject_radius` during osu!catch buzz slider detection

Currently the algorithm considers some buzz sliders as standstills when
in reality they require movement. This happens because `HalfCatcherWidth`
isn't normalized while `exactDistanceMoved` is, leading to an inaccurate
comparison.

`normalized_hitobject_radius` is the normalized value of `HalfCatcherWidth`
and replacing one with the other fixes the problem.

* Rename `normalized_hitobject_radius` to `normalized_half_catcher_width`

The current name is confusing because hit objects have no radius in the
context of osu!catch difficulty calculation. The new name conveys the
actual purpose of the value.

* Only set `normalized_half_catcher_width` in `CatchDifficultyHitObject`

Prevents potential bugs if the value were to be changed in one of the
classes but not in both.

* Use `CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH` directly

Requested during code review.

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>

* Move osu!catch movement diffcalc to an evaluator (#32655)

* Move osu!catch movement state into `CatchDifficultyHitObject`

In order to port `Movement` to an evaluator, the state has to be either
moved elsewhere or calculated inside the evaluator. The latter requires
backtracking for every hit object, which in the worst case is continued
until the beginning of the map is reached. Limiting backtracking can
lead to difficulty value changes.

Thus, the first option was chosen for its simplicity.

* Move osu!catch movement difficulty calculation to an evaluator

Makes the code more in line with the other game modes.

* Add documentation for `CatchDifficultyHitObject` fields

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>

* Move all score-independent bonuses into star rating (#31351)

* basis refactor to allow for more complex SR calculations

* move all possible bonuses into star rating

* decrease star rating scaling to account for overall gains

* add extra FL guard for safety

* move star rating multiplier into a constant

* Reorganise some things

* Add HD and SO to difficulty adjustment mods

* Move non-legacy mod multipliers back to PP

* Some merge fixes

* Fix application of flashlight rating multiplier

* Fix Hidden bonuses being applied when Blinds mod is in use

* Move part of speed OD scaling into difficulty

* Move length bonus back to PP

* Remove blinds special case

* Revert star rating multiplier decrease

* More balancing

---------

Co-authored-by: StanR <hi@stanr.info>

* Add diffcalc considerations for Magnetised mod (#33004)

* Add diffcalc considerations for Magnetised mod

* Make speed reduction scale with power too

* cleaning up

* Update OsuPerformanceCalculator.cs

* Update OsuPerformanceCalculator.cs

* add new check to avoid overestimation

* fix code style

* fix nvicka

* add database attributes

* Refactor

* Rename `Working` to `WorkingBeatmap`

* Remove redundant condition

* Remove useless variable

* Remove `get` wording

* Rename `calculateScoreAtCombo`

* Remove redundant operator

* Add comments to explain how score-based miss count derivations work

* Remove redundant `decimal` calculations

* use static method to improve performance

* move stuff around for readability

* move logic into helper class

* fix the bug

* Delete OsuLegacyScoreProcessor.cs

* Delete ILegacyScoreProcessor.cs

* revert static method for multiplier

* use only basic combo score attribute

* Clean-up

* Remove unused param

* Update osu.Game.Rulesets.Osu/Difficulty/OsuPerformanceCalculator.cs

Co-authored-by: StanR <castl@inbox.ru>

* rename variables

* Add `LegacyScoreUtils`

* Add fail safe

* Move `countMiss`

* Better explain `CalculateRelevantScoreComboPerObject`

* Add `OsuLegacyScoreMissCalculator`

* Move `CalculateScoreAtCombo` and `CalculateRelevantScoreComboPerObject`

* Remove unused variables

* Move `GetLegacyScoreMultiplier`

* Add `estimated` wording

---------

Co-authored-by: wulpine <wulpine@proton.me>
Co-authored-by: James Wilson <tsunyoku@gmail.com>
Co-authored-by: StanR <hi@stanr.info>
Co-authored-by: StanR <castl@inbox.ru>
2025-05-16 23:38:12 +00:00
James Wilson
d22b3fb200 Remove track usage in difficulty and performance calculations (#33132) 2025-05-14 12:37:08 +00:00
KermitNuggies
3165b147ee Use proportion of difficult sliders to better estimate sliderbreaks on classic accuracy scores (#31234)
* scale misscount by proportion of difficult sliders

* cap sliderbreak count at count100 + count50

* use countMiss instead of effectiveMissCount as the base for sliderbreaks

* make code inspector happy + cleanup

* refactor to remove unnecesary calculation and need for new tuple

* scale sliderbreaks with combo

* use aimNoSliders for sliderbreak factor

* code cleanup

* make inspect code happy

* use diffcalcutils

* fix errors (oops)

* scaling changes

* fix div by zeros

* Fix compilation error

* Add online attributes for new difficulty attributes

* Formatting

* Rebase fixes

* Make `CountTopWeightedSliders` to remove weird protected `SliderStrains` list

* Prevent top weighted slider factor from being Infinity

---------

Co-authored-by: tsunyoku <tsunyoku@gmail.com>
2025-05-12 14:05:07 +01:00
StanR
ce73dbbcc6 Add diffcalc considerations for Magnetised mod (#33004)
* Add diffcalc considerations for Magnetised mod

* Make speed reduction scale with power too
2025-05-01 11:52:43 +01:00
Nathan Corbett
4f298760de Use sliders in acc pp if scorev2 is enabled (#32634)
Co-authored-by: StanR <hi@stanr.info>
2025-04-27 11:57:51 +00:00
James Wilson
2aeb80a8bd Move all score-independent bonuses into star rating (#31351)
* basis refactor to allow for more complex SR calculations

* move all possible bonuses into star rating

* decrease star rating scaling to account for overall gains

* add extra FL guard for safety

* move star rating multiplier into a constant

* Reorganise some things

* Add HD and SO to difficulty adjustment mods

* Move non-legacy mod multipliers back to PP

* Some merge fixes

* Fix application of flashlight rating multiplier

* Fix Hidden bonuses being applied when Blinds mod is in use

* Move part of speed OD scaling into difficulty

* Move length bonus back to PP

* Remove blinds special case

* Revert star rating multiplier decrease

* More balancing

---------

Co-authored-by: StanR <hi@stanr.info>
2025-04-27 12:30:05 +01:00
wulpine
7a9d31adb6 Move osu!catch movement diffcalc to an evaluator (#32655)
* Move osu!catch movement state into `CatchDifficultyHitObject`

In order to port `Movement` to an evaluator, the state has to be either
moved elsewhere or calculated inside the evaluator. The latter requires
backtracking for every hit object, which in the worst case is continued
until the beginning of the map is reached. Limiting backtracking can
lead to difficulty value changes.

Thus, the first option was chosen for its simplicity.

* Move osu!catch movement difficulty calculation to an evaluator

Makes the code more in line with the other game modes.

* Add documentation for `CatchDifficultyHitObject` fields

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-04-10 15:47:11 +00:00
StanR
cf7fdc0627 Move difficulty calculation fields from Slider to OsuDifficultyHitObject (#32410)
* Move difficulty calculation fields from `Slider` to `OsuDifficultyHitObject`

* Remove redundant check

* Use `LastObject` where possible

* Update tests

* Make `LazyTravelDistance` `double`

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-04-09 13:37:26 +00:00
StanR
30f9716db9 Reduce RX Ok multiplier (#32434) 2025-04-09 12:48:18 +00:00
James Wilson
69c90f9926 Use Precision.AlmostEquals to compare deviation lower bound (#32694) 2025-04-06 09:36:18 +01:00
wulpine
0c3ee1938e Fix osu!catch SR buzz slider detection (#32412)
* Use `normalized_hitobject_radius` during osu!catch buzz slider detection

Currently the algorithm considers some buzz sliders as standstills when
in reality they require movement. This happens because `HalfCatcherWidth`
isn't normalized while `exactDistanceMoved` is, leading to an inaccurate
comparison.

`normalized_hitobject_radius` is the normalized value of `HalfCatcherWidth`
and replacing one with the other fixes the problem.

* Rename `normalized_hitobject_radius` to `normalized_half_catcher_width`

The current name is confusing because hit objects have no radius in the
context of osu!catch difficulty calculation. The new name conveys the
actual purpose of the value.

* Only set `normalized_half_catcher_width` in `CatchDifficultyHitObject`

Prevents potential bugs if the value were to be changed in one of the
classes but not in both.

* Use `CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH` directly

Requested during code review.

---------

Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-03-24 20:04:40 +03:00
Jay Lawton
8b11be5ac0 osu!taiko skills refactor (#32426)
Co-authored-by: James Wilson <tsunyoku@gmail.com>
2025-03-24 10:07:23 +00:00
Dan Balasescu
9eadc9f68c Merge branch 'master' into pp-dev 2025-03-24 10:49:46 +09:00
StanR
2c88e60ed3 Add difficulty calculation benchmarks (#32542) 2025-03-23 21:08:41 +00:00
360 changed files with 13673 additions and 2487 deletions

View File

@@ -19,6 +19,11 @@ indent_style = space
indent_size = 4 indent_size = 4
trim_trailing_whitespace = true trim_trailing_whitespace = true
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130051/Cannot-resolve-symbol-inspections-incorrectly-firing-for-xmldoc-protected-member-references
resharper_c_sharp_warnings_cs1574_cs1584_cs1581_cs1580_highlighting = hint
# temporary workaround for https://youtrack.jetbrains.com/issue/RIDER-130381/Rider-does-not-respect-propagated-NoWarn-CS1591?backToIssues=false
dotnet_diagnostic.CS1591.severity = none
#license header #license header
file_header_template = Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text. file_header_template = Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.\nSee the LICENCE file in the repository root for full licence text.

View File

@@ -10,7 +10,7 @@
<EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk> <EmbedAssembliesIntoApk>true</EmbedAssembliesIntoApk>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="ppy.osu.Framework.Android" Version="2025.908.0" /> <PackageReference Include="ppy.osu.Framework.Android" Version="2025.1028.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup> <PropertyGroup>
<!-- Fody does not handle Android build well, and warns when unchanged. <!-- Fody does not handle Android build well, and warns when unchanged.

View File

@@ -189,7 +189,7 @@ namespace osu.Desktop
} }
// user party // user party
if (!hideIdentifiableInformation && multiplayerClient.Room != null) if (!hideIdentifiableInformation && multiplayerClient.Room != null && multiplayerClient.Room.Settings.MatchType != MatchType.Matchmaking)
{ {
MultiplayerRoom room = multiplayerClient.Room; MultiplayerRoom room = multiplayerClient.Room;

View File

@@ -0,0 +1,77 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.IO;
using BenchmarkDotNet.Attributes;
using osu.Framework.IO.Stores;
using osu.Game.Beatmaps;
using osu.Game.IO;
using osu.Game.IO.Archives;
using osu.Game.Rulesets.Catch;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mania;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Taiko;
using osu.Game.Tests.Resources;
namespace osu.Game.Benchmarks
{
public class BenchmarkDifficultyCalculation : BenchmarkTest
{
private DifficultyCalculator osuCalculator = null!;
private DifficultyCalculator taikoCalculator = null!;
private DifficultyCalculator catchCalculator = null!;
private DifficultyCalculator maniaCalculator = null!;
public override void SetUp()
{
using var resources = new DllResourceStore(typeof(TestResources).Assembly);
using var archive = resources.GetStream("Resources/Archives/241526 Soleily - Renatus.osz");
using var archiveReader = new ZipArchiveReader(archive);
var osuBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Gamu) [Insane].osu");
var taikoBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (MMzz) [Oni].osu");
var catchBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (Deif) [Salad].osu");
var maniaBeatmap = readBeatmap(archiveReader, "Soleily - Renatus (ExPew) [Another].osu");
osuCalculator = new OsuRuleset().CreateDifficultyCalculator(osuBeatmap);
taikoCalculator = new TaikoRuleset().CreateDifficultyCalculator(taikoBeatmap);
catchCalculator = new CatchRuleset().CreateDifficultyCalculator(catchBeatmap);
maniaCalculator = new ManiaRuleset().CreateDifficultyCalculator(maniaBeatmap);
}
private WorkingBeatmap readBeatmap(ZipArchiveReader archiveReader, string beatmapName)
{
using var beatmapStream = new MemoryStream();
archiveReader.GetStream(beatmapName).CopyTo(beatmapStream);
beatmapStream.Seek(0, SeekOrigin.Begin);
using var reader = new LineBufferedReader(beatmapStream);
var decoder = Beatmaps.Formats.Decoder.GetDecoder<Beatmap>(reader);
return new FlatWorkingBeatmap(decoder.Decode(reader));
}
[Benchmark]
public void CalculateDifficultyOsu() => osuCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyTaiko() => taikoCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyCatch() => catchCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyMania() => maniaCalculator.Calculate();
[Benchmark]
public void CalculateDifficultyOsuHundredTimes()
{
for (int i = 0; i < 100; i++)
{
osuCalculator.Calculate();
}
}
}
}

View File

@@ -157,7 +157,7 @@ namespace osu.Game.Rulesets.Catch.Tests
} }
[Test] [Test]
public void TestTinyDropletMissPreservesCatcherState() public void TestTinyDropletMissChangesCatcherState()
{ {
AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit AddStep("catch hyper kiai fruit", () => attemptCatch(new TestKiaiFruit
{ {
@@ -165,8 +165,8 @@ namespace osu.Game.Rulesets.Catch.Tests
})); }));
AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet())); AddStep("catch tiny droplet", () => attemptCatch(new TinyDroplet()));
AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 })); AddStep("miss tiny droplet", () => attemptCatch(new TinyDroplet { X = 100 }));
// catcher state and hyper dash state is preserved // catcher state is changed but hyper dash state is preserved
checkState(CatcherAnimationState.Kiai); checkState(CatcherAnimationState.Fail);
checkHyperDash(true); checkHyperDash(true);
} }

View File

@@ -24,7 +24,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty
private float halfCatcherWidth; private float halfCatcherWidth;
public override int Version => 20250306; public override int Version => 20251020;
public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public CatchDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)

View File

@@ -3,13 +3,13 @@
using System; using System;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Scoring.Legacy; using osu.Game.Scoring.Legacy;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Catch.Difficulty namespace osu.Game.Rulesets.Catch.Difficulty
{ {
@@ -51,15 +51,13 @@ namespace osu.Game.Rulesets.Catch.Difficulty
// Combo scaling // Combo scaling
if (catchAttributes.MaxCombo > 0) if (catchAttributes.MaxCombo > 0)
value *= Math.Min(Math.Pow(score.MaxCombo, 0.8) / Math.Pow(catchAttributes.MaxCombo, 0.8), 1.0); value *= Math.Min(Math.Pow(score.MaxCombo, 0.35) / Math.Pow(catchAttributes.MaxCombo, 0.35), 1.0);
var difficulty = score.BeatmapInfo!.Difficulty.Clone(); var difficulty = score.BeatmapInfo!.Difficulty.Clone();
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty)); score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
var track = new TrackVirtual(10000); double clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
double clockRate = track.Rate;
// this is the same as osu!, so there's potential to share the implementation... maybe // this is the same as osu!, so there's potential to share the implementation... maybe
double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate;

View File

@@ -0,0 +1,65 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Catch.Difficulty.Evaluators
{
public static class MovementEvaluator
{
private const double direction_change_bonus = 21.0;
public static double EvaluateDifficultyOf(DifficultyHitObject current, double catcherSpeedMultiplier)
{
var catchCurrent = (CatchDifficultyHitObject)current;
var catchLast = (CatchDifficultyHitObject)current.Previous(0);
var catchLastLast = (CatchDifficultyHitObject)current.Previous(1);
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(catchCurrent.DistanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);
double edgeDashBonus = 0;
// Direction change bonus.
if (Math.Abs(catchCurrent.DistanceMoved) > 0.1)
{
if (current.Index >= 1 && Math.Abs(catchLast.DistanceMoved) > 0.1 && Math.Sign(catchCurrent.DistanceMoved) != Math.Sign(catchLast.DistanceMoved))
{
double bonusFactor = Math.Min(50, Math.Abs(catchCurrent.DistanceMoved)) / 50;
double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(catchLast.DistanceMoved)) / 70, 0.38);
distanceAddition += direction_change_bonus / Math.Sqrt(catchLast.StrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0);
}
// Base bonus for every movement, giving some weight to streams.
distanceAddition += 12.5 * Math.Min(Math.Abs(catchCurrent.DistanceMoved), CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2)
/ (CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 6) / sqrtStrain;
}
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
if (!catchCurrent.LastObject.HyperDash)
edgeDashBonus += 5.7;
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and NORMALIZED_HALF_CATCHER_WIDTH)
if (current.Index >= 2 && Math.Abs(catchCurrent.ExactDistanceMoved) <= CatchDifficultyHitObject.NORMALIZED_HALF_CATCHER_WIDTH * 2
&& catchCurrent.ExactDistanceMoved == -catchLast.ExactDistanceMoved && catchLast.ExactDistanceMoved == -catchLastLast.ExactDistanceMoved
&& catchCurrent.StrainTime == catchLast.StrainTime && catchLast.StrainTime == catchLastLast.StrainTime)
distanceAddition = 0;
return distanceAddition / weightedStrainTime;
}
}
}

View File

@@ -11,15 +11,49 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
{ {
public class CatchDifficultyHitObject : DifficultyHitObject public class CatchDifficultyHitObject : DifficultyHitObject
{ {
private const float normalized_hitobject_radius = 41.0f; public const float NORMALIZED_HALF_CATCHER_WIDTH = 41.0f;
private const float absolute_player_positioning_error = 16.0f;
public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject; public new PalpableCatchHitObject BaseObject => (PalpableCatchHitObject)base.BaseObject;
public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject; public new PalpableCatchHitObject LastObject => (PalpableCatchHitObject)base.LastObject;
/// <summary>
/// Normalized position of <see cref="BaseObject"/>.
/// </summary>
public readonly float NormalizedPosition; public readonly float NormalizedPosition;
/// <summary>
/// Normalized position of <see cref="LastObject"/>.
/// </summary>
public readonly float LastNormalizedPosition; public readonly float LastNormalizedPosition;
/// <summary>
/// Normalized position of the player required to catch <see cref="BaseObject"/>, assuming the player moves as little as possible.
/// </summary>
public float PlayerPosition { get; private set; }
/// <summary>
/// Normalized position of the player after catching <see cref="LastObject"/>.
/// </summary>
public float LastPlayerPosition { get; private set; }
/// <summary>
/// Normalized distance between <see cref="LastPlayerPosition"/> and <see cref="PlayerPosition"/>.
/// </summary>
/// <remarks>
/// The sign of the value indicates the direction of the movement: negative is left and positive is right.
/// </remarks>
public float DistanceMoved { get; private set; }
/// <summary>
/// Normalized distance the player has to move from <see cref="LastPlayerPosition"/> in order to catch <see cref="BaseObject"/> at its <see cref="NormalizedPosition"/>.
/// </summary>
/// <remarks>
/// The sign of the value indicates the direction of the movement: negative is left and positive is right.
/// </remarks>
public float ExactDistanceMoved { get; private set; }
/// <summary> /// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="CatchDifficultyHitObject"/>, with a minimum of 40ms. /// Milliseconds elapsed since the start time of the previous <see cref="CatchDifficultyHitObject"/>, with a minimum of 40ms.
/// </summary> /// </summary>
@@ -29,13 +63,35 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Preprocessing
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
// We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps. // We will scale everything by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = normalized_hitobject_radius / halfCatcherWidth; float scalingFactor = NORMALIZED_HALF_CATCHER_WIDTH / halfCatcherWidth;
NormalizedPosition = BaseObject.EffectiveX * scalingFactor; NormalizedPosition = BaseObject.EffectiveX * scalingFactor;
LastNormalizedPosition = LastObject.EffectiveX * scalingFactor; LastNormalizedPosition = LastObject.EffectiveX * scalingFactor;
// Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure // Every strain interval is hard capped at the equivalent of 375 BPM streaming speed as a safety measure
StrainTime = Math.Max(40, DeltaTime); StrainTime = Math.Max(40, DeltaTime);
setMovementState();
}
private void setMovementState()
{
LastPlayerPosition = Index == 0 ? LastNormalizedPosition : ((CatchDifficultyHitObject)Previous(0)).PlayerPosition;
PlayerPosition = Math.Clamp(
LastPlayerPosition,
NormalizedPosition - (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error),
NormalizedPosition + (NORMALIZED_HALF_CATCHER_WIDTH - absolute_player_positioning_error)
);
DistanceMoved = PlayerPosition - LastPlayerPosition;
// For the exact position we consider that the catcher is in the correct position for both objects
ExactDistanceMoved = NormalizedPosition - LastPlayerPosition;
// After a hyperdash we ARE in the correct position. Always!
if (LastObject.HyperDash)
PlayerPosition = NormalizedPosition;
} }
} }
} }

View File

@@ -1,8 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using osu.Game.Rulesets.Catch.Difficulty.Evaluators;
using osu.Game.Rulesets.Catch.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@@ -11,10 +10,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
{ {
public class Movement : StrainDecaySkill public class Movement : StrainDecaySkill
{ {
private const float absolute_player_positioning_error = 16f;
private const float normalized_hitobject_radius = 41.0f;
private const double direction_change_bonus = 21.0;
protected override double SkillMultiplier => 1; protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 0.2; protected override double StrainDecayBase => 0.2;
@@ -24,12 +19,6 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected readonly float HalfCatcherWidth; protected readonly float HalfCatcherWidth;
private float? lastPlayerPosition;
private float lastDistanceMoved;
private float lastExactDistanceMoved;
private double lastStrainTime;
private bool isInBuzzSection;
/// <summary> /// <summary>
/// The speed multiplier applied to the player's catcher. /// The speed multiplier applied to the player's catcher.
/// </summary> /// </summary>
@@ -49,80 +38,7 @@ namespace osu.Game.Rulesets.Catch.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
var catchCurrent = (CatchDifficultyHitObject)current; return MovementEvaluator.EvaluateDifficultyOf(current, catcherSpeedMultiplier);
lastPlayerPosition ??= catchCurrent.LastNormalizedPosition;
float playerPosition = Math.Clamp(
lastPlayerPosition.Value,
catchCurrent.NormalizedPosition - (normalized_hitobject_radius - absolute_player_positioning_error),
catchCurrent.NormalizedPosition + (normalized_hitobject_radius - absolute_player_positioning_error)
);
float distanceMoved = playerPosition - lastPlayerPosition.Value;
// For the exact position we consider that the catcher is in the correct position for both objects
float exactDistanceMoved = catchCurrent.NormalizedPosition - lastPlayerPosition.Value;
double weightedStrainTime = catchCurrent.StrainTime + 13 + (3 / catcherSpeedMultiplier);
double distanceAddition = (Math.Pow(Math.Abs(distanceMoved), 1.3) / 510);
double sqrtStrain = Math.Sqrt(weightedStrainTime);
double edgeDashBonus = 0;
// Direction change bonus.
if (Math.Abs(distanceMoved) > 0.1)
{
if (Math.Abs(lastDistanceMoved) > 0.1 && Math.Sign(distanceMoved) != Math.Sign(lastDistanceMoved))
{
double bonusFactor = Math.Min(50, Math.Abs(distanceMoved)) / 50;
double antiflowFactor = Math.Max(Math.Min(70, Math.Abs(lastDistanceMoved)) / 70, 0.38);
distanceAddition += direction_change_bonus / Math.Sqrt(lastStrainTime + 16) * bonusFactor * antiflowFactor * Math.Max(1 - Math.Pow(weightedStrainTime / 1000, 3), 0);
}
// Base bonus for every movement, giving some weight to streams.
distanceAddition += 12.5 * Math.Min(Math.Abs(distanceMoved), normalized_hitobject_radius * 2) / (normalized_hitobject_radius * 6) / sqrtStrain;
}
// Bonus for edge dashes.
if (catchCurrent.LastObject.DistanceToHyperDash <= 20.0f)
{
if (!catchCurrent.LastObject.HyperDash)
edgeDashBonus += 5.7;
else
{
// After a hyperdash we ARE in the correct position. Always!
playerPosition = catchCurrent.NormalizedPosition;
}
distanceAddition *= 1.0 + edgeDashBonus * ((20 - catchCurrent.LastObject.DistanceToHyperDash) / 20)
* Math.Pow((Math.Min(catchCurrent.StrainTime * catcherSpeedMultiplier, 265) / 265), 1.5); // Edge Dashes are easier at lower ms values
}
// There is an edge case where horizontal back and forth sliders create "buzz" patterns which are repeated "movements" with a distance lower than
// the platter's width but high enough to be considered a movement due to the absolute_player_positioning_error and normalized_hitobject_radius offsets
// We are detecting this exact scenario. The first back and forth is counted but all subsequent ones are nullified.
// To achieve that, we need to store the exact distances (distance ignoring absolute_player_positioning_error and normalized_hitobject_radius)
if (Math.Abs(exactDistanceMoved) <= HalfCatcherWidth * 2 && exactDistanceMoved == -lastExactDistanceMoved && catchCurrent.StrainTime == lastStrainTime)
{
if (isInBuzzSection)
distanceAddition = 0;
else
isInBuzzSection = true;
}
else
{
isInBuzzSection = false;
}
lastPlayerPosition = playerPosition;
lastDistanceMoved = distanceMoved;
lastStrainTime = catchCurrent.StrainTime;
lastExactDistanceMoved = exactDistanceMoved;
return distanceAddition / weightedStrainTime;
} }
} }
} }

View File

@@ -51,7 +51,8 @@ namespace osu.Game.Rulesets.Catch.Mods
return string.Empty; return string.Empty;
string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; string format(string acronym, DifficultyBindable bindable)
=> $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}";
} }
} }

View File

@@ -4,6 +4,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using osu.Game.Audio; using osu.Game.Audio;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Rulesets.Catch.Judgements; using osu.Game.Rulesets.Catch.Judgements;
using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Objects.Types;
@@ -53,13 +54,25 @@ namespace osu.Game.Rulesets.Catch.Objects
public override IEnumerable<string> LookupNames => lookup_names; public override IEnumerable<string> LookupNames => lookup_names;
public BananaHitSampleInfo(int volume = 100) public BananaHitSampleInfo()
: base(string.Empty, volume: volume) : this(string.Empty)
{ {
} }
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default, Optional<bool> newEditorAutoBank = default) public BananaHitSampleInfo(HitSampleInfo info)
=> new BananaHitSampleInfo(newVolume.GetOr(Volume)); : this(info.Name, info.Bank, info.Suffix, info.Volume, info.EditorAutoBank, info.UseBeatmapSamples)
{
}
private BananaHitSampleInfo(string name, string bank = SampleControlPoint.DEFAULT_BANK, string? suffix = null, int volume = 100, bool editorAutoBank = true, bool useBeatmapSamples = false)
: base(name, bank, suffix, volume, editorAutoBank, useBeatmapSamples)
{
}
public sealed override HitSampleInfo With(Optional<string> newName = default, Optional<string> newBank = default, Optional<string?> newSuffix = default, Optional<int> newVolume = default,
Optional<bool> newEditorAutoBank = default, Optional<bool> newUseBeatmapSamples = default)
=> new BananaHitSampleInfo(newName.GetOr(Name), newBank.GetOr(Bank), newSuffix.GetOr(Suffix), newVolume.GetOr(Volume),
newEditorAutoBank.GetOr(EditorAutoBank), newUseBeatmapSamples.GetOr(UseBeatmapSamples));
public bool Equals(BananaHitSampleInfo? other) public bool Equals(BananaHitSampleInfo? other)
=> other != null; => other != null;

View File

@@ -43,7 +43,7 @@ namespace osu.Game.Rulesets.Catch.Objects
{ {
StartTime = time, StartTime = time,
BananaIndex = count, BananaIndex = count,
Samples = new List<HitSampleInfo> { new Banana.BananaHitSampleInfo(CreateHitSampleInfo().Volume) } Samples = new List<HitSampleInfo> { new Banana.BananaHitSampleInfo(CreateHitSampleInfo()) }
}); });
count++; count++;

View File

@@ -224,7 +224,20 @@ namespace osu.Game.Rulesets.Catch.UI
addLighting(result, drawableObject.AccentColour.Value, positionInStack.X); addLighting(result, drawableObject.AccentColour.Value, positionInStack.X);
} }
// droplet doesn't affect the catcher state if (result.IsHit)
CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle;
else if (hitObject is not Banana)
CurrentState = CatcherAnimationState.Fail;
if (palpableObject.HitObject.LastInCombo)
{
if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result))
Explode();
else
Drop();
}
// droplet doesn't affect hyperdash state
if (hitObject is TinyDroplet) return; if (hitObject is TinyDroplet) return;
// if a hyper fruit was already handled this frame, just go where it says to go. // if a hyper fruit was already handled this frame, just go where it says to go.
@@ -244,19 +257,6 @@ namespace osu.Game.Rulesets.Catch.UI
else else
SetHyperDashState(); SetHyperDashState();
} }
if (result.IsHit)
CurrentState = hitObject.Kiai ? CatcherAnimationState.Kiai : CatcherAnimationState.Idle;
else if (!(hitObject is Banana))
CurrentState = CatcherAnimationState.Fail;
if (palpableObject.HitObject.LastInCombo)
{
if (result.Judgement is CatchJudgement catchJudgement && catchJudgement.ShouldExplodeFor(result))
Explode();
else
Drop();
}
} }
public void OnRevertResult(JudgementResult result) public void OnRevertResult(JudgementResult result)

View File

@@ -0,0 +1,37 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class IndividualStrainEvaluator
{
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
// We award a bonus if this note starts and ends before the end of another hold note.
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
{
holdFactor = 1.25;
break;
}
}
return 2.0 * holdFactor;
}
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Mania.Difficulty.Evaluators
{
public class OverallStrainEvaluator
{
private const double release_threshold = 30;
public static double EvaluateDifficultyOf(DifficultyHitObject current)
{
var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information
double holdFactor = 1.0; // Factor to all additional strains in case something else is held
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
foreach (var maniaPrevious in maniaCurrent.PreviousHitObjects)
{
if (maniaPrevious is null)
continue;
// The current note is overlapped if a previous note or end is overlapping the current note body
isOverlapping |= Precision.DefinitelyBigger(maniaPrevious.EndTime, startTime, 1) &&
Precision.DefinitelyBigger(endTime, maniaPrevious.EndTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1);
// We give a slight bonus to everything if something is held meanwhile
if (Precision.DefinitelyBigger(maniaPrevious.EndTime, endTime, 1) &&
Precision.DefinitelyBigger(startTime, maniaPrevious.StartTime, 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - maniaPrevious.EndTime));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
return (1 + holdAddition) * holdFactor;
}
}
}

View File

@@ -65,13 +65,22 @@ namespace osu.Game.Rulesets.Mania.Difficulty
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{ {
var sortedObjects = beatmap.HitObjects.ToArray(); var sortedObjects = beatmap.HitObjects.ToArray();
int totalColumns = ((ManiaBeatmap)beatmap).TotalColumns;
LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime))); LegacySortHelper<HitObject>.Sort(sortedObjects, Comparer<HitObject>.Create((a, b) => (int)Math.Round(a.StartTime) - (int)Math.Round(b.StartTime)));
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
List<DifficultyHitObject>[] perColumnObjects = new List<DifficultyHitObject>[totalColumns];
for (int column = 0; column < totalColumns; column++)
perColumnObjects[column] = new List<DifficultyHitObject>();
for (int i = 1; i < sortedObjects.Length; i++) for (int i = 1; i < sortedObjects.Length; i++)
objects.Add(new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, objects.Count)); {
var currentObject = new ManiaDifficultyHitObject(sortedObjects[i], sortedObjects[i - 1], clockRate, objects, perColumnObjects, objects.Count);
objects.Add(currentObject);
perColumnObjects[currentObject.Column].Add(currentObject);
}
return objects; return objects;
} }

View File

@@ -12,9 +12,59 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Preprocessing
{ {
public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject; public new ManiaHitObject BaseObject => (ManiaHitObject)base.BaseObject;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, int index) private readonly List<DifficultyHitObject>[] perColumnObjects;
private readonly int columnIndex;
public readonly int Column;
// The hit object earlier in time than this note in each column
public readonly ManiaDifficultyHitObject?[] PreviousHitObjects;
public readonly double ColumnStrainTime;
public ManiaDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, List<DifficultyHitObject>[] perColumnObjects, int index)
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
int totalColumns = perColumnObjects.Length;
this.perColumnObjects = perColumnObjects;
Column = BaseObject.Column;
columnIndex = perColumnObjects[Column].Count;
PreviousHitObjects = new ManiaDifficultyHitObject[totalColumns];
ColumnStrainTime = StartTime - PrevInColumn(0)?.StartTime ?? StartTime;
if (index > 0)
{
ManiaDifficultyHitObject prevNote = (ManiaDifficultyHitObject)objects[index - 1];
for (int i = 0; i < prevNote.PreviousHitObjects.Length; i++)
PreviousHitObjects[i] = prevNote.PreviousHitObjects[i];
// intentionally depends on processing order to match live.
PreviousHitObjects[prevNote.Column] = prevNote;
}
}
/// <summary>
/// The previous object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="backwardsIndex">The number of notes to go back.</param>
/// <returns>The object in this column <paramref name="backwardsIndex"/> notes back, or null if this is the first note in the column.</returns>
public ManiaDifficultyHitObject? PrevInColumn(int backwardsIndex)
{
int index = columnIndex - (backwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
}
/// <summary>
/// The next object in the same column as this <see cref="ManiaDifficultyHitObject"/>, exclusive of Long Note tails.
/// </summary>
/// <param name="forwardsIndex">The number of notes to go forward.</param>
/// <returns>The object in this column <paramref name="forwardsIndex"/> notes forward, or null if this is the last note in the column.</returns>
public ManiaDifficultyHitObject? NextInColumn(int forwardsIndex)
{
int index = columnIndex + (forwardsIndex + 1);
return index >= 0 && index < perColumnObjects[Column].Count ? (ManiaDifficultyHitObject)perColumnObjects[Column][index] : null;
} }
} }
} }

View File

@@ -2,10 +2,9 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using osu.Framework.Utils;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Skills; using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Mania.Difficulty.Evaluators;
using osu.Game.Rulesets.Mania.Difficulty.Preprocessing; using osu.Game.Rulesets.Mania.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
@@ -15,23 +14,17 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
{ {
private const double individual_decay_base = 0.125; private const double individual_decay_base = 0.125;
private const double overall_decay_base = 0.30; private const double overall_decay_base = 0.30;
private const double release_threshold = 30;
protected override double SkillMultiplier => 1; protected override double SkillMultiplier => 1;
protected override double StrainDecayBase => 1; protected override double StrainDecayBase => 1;
private readonly double[] startTimes;
private readonly double[] endTimes;
private readonly double[] individualStrains; private readonly double[] individualStrains;
private double highestIndividualStrain;
private double individualStrain;
private double overallStrain; private double overallStrain;
public Strain(Mod[] mods, int totalColumns) public Strain(Mod[] mods, int totalColumns)
: base(mods) : base(mods)
{ {
startTimes = new double[totalColumns];
endTimes = new double[totalColumns];
individualStrains = new double[totalColumns]; individualStrains = new double[totalColumns];
overallStrain = 1; overallStrain = 1;
} }
@@ -39,65 +32,24 @@ namespace osu.Game.Rulesets.Mania.Difficulty.Skills
protected override double StrainValueOf(DifficultyHitObject current) protected override double StrainValueOf(DifficultyHitObject current)
{ {
var maniaCurrent = (ManiaDifficultyHitObject)current; var maniaCurrent = (ManiaDifficultyHitObject)current;
double startTime = maniaCurrent.StartTime;
double endTime = maniaCurrent.EndTime;
int column = maniaCurrent.BaseObject.Column;
bool isOverlapping = false;
double closestEndTime = Math.Abs(endTime - startTime); // Lowest value we can assume with the current information individualStrains[maniaCurrent.Column] = applyDecay(individualStrains[maniaCurrent.Column], maniaCurrent.ColumnStrainTime, individual_decay_base);
double holdFactor = 1.0; // Factor to all additional strains in case something else is held individualStrains[maniaCurrent.Column] += IndividualStrainEvaluator.EvaluateDifficultyOf(current);
double holdAddition = 0; // Addition to the current note in case it's a hold and has to be released awkwardly
for (int i = 0; i < endTimes.Length; ++i) // Take the hardest individualStrain for notes that happen at the same time (in a chord).
{ // This is to ensure the order in which the notes are processed does not affect the resultant total strain.
// The current note is overlapped if a previous note or end is overlapping the current note body highestIndividualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(highestIndividualStrain, individualStrains[maniaCurrent.Column]) : individualStrains[maniaCurrent.Column];
isOverlapping |= Precision.DefinitelyBigger(endTimes[i], startTime, 1) &&
Precision.DefinitelyBigger(endTime, endTimes[i], 1) &&
Precision.DefinitelyBigger(startTime, startTimes[i], 1);
// We give a slight bonus to everything if something is held meanwhile overallStrain = applyDecay(overallStrain, maniaCurrent.DeltaTime, overall_decay_base);
if (Precision.DefinitelyBigger(endTimes[i], endTime, 1) && overallStrain += OverallStrainEvaluator.EvaluateDifficultyOf(current);
Precision.DefinitelyBigger(startTime, startTimes[i], 1))
holdFactor = 1.25;
closestEndTime = Math.Min(closestEndTime, Math.Abs(endTime - endTimes[i]));
}
// The hold addition is given if there was an overlap, however it is only valid if there are no other note with a similar ending.
// Releasing multiple notes is just as easy as releasing 1. Nerfs the hold addition by half if the closest release is release_threshold away.
// holdAddition
// ^
// 1.0 + - - - - - -+-----------
// | /
// 0.5 + - - - - -/ Sigmoid Curve
// | /|
// 0.0 +--------+-+---------------> Release Difference / ms
// release_threshold
if (isOverlapping)
holdAddition = DifficultyCalculationUtils.Logistic(x: closestEndTime, multiplier: 0.27, midpointOffset: release_threshold);
// Decay and increase individualStrains in own column
individualStrains[column] = applyDecay(individualStrains[column], startTime - startTimes[column], individual_decay_base);
individualStrains[column] += 2.0 * holdFactor;
// For notes at the same time (in a chord), the individualStrain should be the hardest individualStrain out of those columns
individualStrain = maniaCurrent.DeltaTime <= 1 ? Math.Max(individualStrain, individualStrains[column]) : individualStrains[column];
// Decay and increase overallStrain
overallStrain = applyDecay(overallStrain, current.DeltaTime, overall_decay_base);
overallStrain += (1 + holdAddition) * holdFactor;
// Update startTimes and endTimes arrays
startTimes[column] = startTime;
endTimes[column] = endTime;
// By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section. // By subtracting CurrentStrain, this skill effectively only considers the maximum strain of any one hitobject within each strain section.
return individualStrain + overallStrain - CurrentStrain; return highestIndividualStrain + overallStrain - CurrentStrain;
} }
protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) protected override double CalculateInitialStrain(double offset, DifficultyHitObject current) =>
=> applyDecay(individualStrain, offset - current.Previous(0).StartTime, individual_decay_base) applyDecay(highestIndividualStrain, offset - current.Previous(0).StartTime, individual_decay_base)
+ applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base); + applyDecay(overallStrain, offset - current.Previous(0).StartTime, overall_decay_base);
private double applyDecay(double value, double deltaTime, double decayBase) private double applyDecay(double value, double deltaTime, double decayBase)
=> value * Math.Pow(decayBase, deltaTime / 1000); => value * Math.Pow(decayBase, deltaTime / 1000);

View File

@@ -7,5 +7,20 @@ namespace osu.Game.Rulesets.Mania.Mods
{ {
public class ManiaModDifficultyAdjust : ModDifficultyAdjust public class ManiaModDifficultyAdjust : ModDifficultyAdjust
{ {
public override DifficultyBindable OverallDifficulty { get; } = new DifficultyBindable
{
Precision = 0.1f,
MinValue = 0,
MaxValue = 10,
// Use larger extended limits for mania to include OD values that occur with EZ or HR enabled
#if !DEBUG
ExtendedMaxValue = 15,
ExtendedMinValue = -15,
#else
ExtendedMinValue = -250,
ExtendedMaxValue = 50,
#endif
ReadCurrentFromDifficulty = diff => diff.OverallDifficulty,
};
} }
} }

View File

@@ -9,6 +9,7 @@ using osu.Game.Rulesets.Mods;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
using System.Collections.Generic; using System.Collections.Generic;
using osu.Framework.Localisation; using osu.Framework.Localisation;
using osu.Framework.Utils;
using osu.Game.Graphics; using osu.Game.Graphics;
using osu.Game.Rulesets.Mania.Beatmaps; using osu.Game.Rulesets.Mania.Beatmaps;
@@ -34,6 +35,8 @@ namespace osu.Game.Rulesets.Mania.Mods
{ {
var maniaBeatmap = (ManiaBeatmap)beatmap; var maniaBeatmap = (ManiaBeatmap)beatmap;
double mostCommonBeatLengthBefore = beatmap.GetMostCommonBeatLength();
var newObjects = new List<ManiaHitObject>(); var newObjects = new List<ManiaHitObject>();
foreach (var h in beatmap.HitObjects.OfType<HoldNote>()) foreach (var h in beatmap.HitObjects.OfType<HoldNote>())
@@ -48,6 +51,17 @@ namespace osu.Game.Rulesets.Mania.Mods
} }
maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType<Note>().Concat(newObjects).OrderBy(h => h.StartTime).ToList(); maniaBeatmap.HitObjects = maniaBeatmap.HitObjects.OfType<Note>().Concat(newObjects).OrderBy(h => h.StartTime).ToList();
double mostCommonBeatLengthAfter = beatmap.GetMostCommonBeatLength();
// the process of removing hold notes can result in shortening the beatmap's play time,
// and therefore, as a side effect, changing the most common BPM, which will change scroll speed.
// to compensate for this, apply a multiplier to effect points in order to maintain the beatmap's original intended scroll speed.
if (!Precision.AlmostEquals(mostCommonBeatLengthBefore, mostCommonBeatLengthAfter))
{
foreach (var effectPoint in beatmap.ControlPointInfo.EffectPoints)
effectPoint.ScrollSpeed *= mostCommonBeatLengthBefore / mostCommonBeatLengthAfter;
}
} }
} }
} }

View File

@@ -22,7 +22,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
private Drawable noteAnimation = null!; private Drawable noteAnimation = null!;
private float? minimumColumnWidth; private float? widthForNoteHeightScale;
public LegacyNotePiece() public LegacyNotePiece()
{ {
@@ -33,7 +33,7 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(ISkinSource skin, IScrollingInfo scrollingInfo) private void load(ISkinSource skin, IScrollingInfo scrollingInfo)
{ {
minimumColumnWidth = skin.GetConfig<ManiaSkinConfigurationLookup, float>(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.MinimumColumnWidth))?.Value; widthForNoteHeightScale = skin.GetConfig<ManiaSkinConfigurationLookup, float>(new ManiaSkinConfigurationLookup(LegacyManiaSkinConfigurationLookups.WidthForNoteHeightScale))?.Value;
InternalChild = directionContainer = new Container InternalChild = directionContainer = new Container
{ {
@@ -60,9 +60,8 @@ namespace osu.Game.Rulesets.Mania.Skinning.Legacy
if (texture != null) if (texture != null)
{ {
// The height is scaled to the minimum column width, if provided. float noteHeight = widthForNoteHeightScale ?? DrawWidth;
float minimumWidth = minimumColumnWidth ?? DrawWidth; noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, noteHeight), texture.DisplayWidth);
noteAnimation.Scale = Vector2.Divide(new Vector2(DrawWidth, minimumWidth), texture.DisplayWidth);
} }
} }

View File

@@ -15,22 +15,22 @@ namespace osu.Game.Rulesets.Osu.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests"; protected override string ResourceAssembly => "osu.Game.Rulesets.Osu.Tests";
[TestCase(6.7331304290522747d, 239, "diffcalc-test")] [TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")] [TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")] [TestCase(0.43333836671191595d, 4, "very-fast-slider")]
[TestCase(0.14143808967817237d, 2, "nan-slider")] [TestCase(0.13841532030395723d, 2, "nan-slider")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(9.6779746353001634d, 239, "diffcalc-test")] [TestCase(9.6491691624112761d, 239, "diffcalc-test")]
[TestCase(1.7691451263718989d, 54, "zero-length-sliders")] [TestCase(1.756936832498702d, 54, "zero-length-sliders")]
[TestCase(0.55785578988249407d, 4, "very-fast-slider")] [TestCase(0.57771197086735004d, 4, "very-fast-slider")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModDoubleTime());
[TestCase(6.7331304290522747d, 239, "diffcalc-test")] [TestCase(6.6232533278125061d, 239, "diffcalc-test")]
[TestCase(1.4602604078137214d, 54, "zero-length-sliders")] [TestCase(1.5045783545699611d, 54, "zero-length-sliders")]
[TestCase(0.43052813047866129d, 4, "very-fast-slider")] [TestCase(0.43333836671191595d, 4, "very-fast-slider")]
public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name) public void TestClassicMod(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic()); => Test(expectedStarRating, expectedMaxCombo, name, new OsuModClassic());

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 644 KiB

View File

@@ -191,6 +191,7 @@ namespace osu.Game.Rulesets.Osu.Tests
} }
[Test] [Test]
[FlakyTest]
public void TestRewind() public void TestRewind()
{ {
AddStep("set manual clock", () => manualClock = new ManualClock AddStep("set manual clock", () => manualClock = new ManualClock

View File

@@ -12,7 +12,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
public static class AimEvaluator public static class AimEvaluator
{ {
private const double wide_angle_multiplier = 1.5; private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.6; private const double acute_angle_multiplier = 2.55;
private const double slider_multiplier = 1.35; private const double slider_multiplier = 1.35;
private const double velocity_change_multiplier = 0.75; private const double velocity_change_multiplier = 0.75;
private const double wiggle_multiplier = 1.02; private const double wiggle_multiplier = 1.02;
@@ -34,12 +34,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuCurrObj = (OsuDifficultyHitObject)current; var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)current.Previous(0); var osuLastObj = (OsuDifficultyHitObject)current.Previous(0);
var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1); var osuLastLastObj = (OsuDifficultyHitObject)current.Previous(1);
var osuLast2Obj = (OsuDifficultyHitObject)current.Previous(2);
const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS; const int radius = OsuDifficultyHitObject.NORMALISED_RADIUS;
const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER; const int diameter = OsuDifficultyHitObject.NORMALISED_DIAMETER;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle. // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.StrainTime; double currVelocity = osuCurrObj.LazyJumpDistance / osuCurrObj.AdjustedDeltaTime;
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object. // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider && withSliderTravelDistance) if (osuLastObj.BaseObject is Slider && withSliderTravelDistance)
@@ -51,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
} }
// As above, do the same for the previous hitobject. // As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.StrainTime; double prevVelocity = osuLastObj.LazyJumpDistance / osuLastObj.AdjustedDeltaTime;
if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance) if (osuLastLastObj.BaseObject is Slider && withSliderTravelDistance)
{ {
@@ -69,59 +70,77 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double aimStrain = currVelocity; // Start strain with regular velocity. double aimStrain = currVelocity; // Start strain with regular velocity.
if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same. if (osuCurrObj.Angle != null && osuLastObj.Angle != null)
{ {
if (osuCurrObj.Angle != null && osuLastObj.Angle != null) double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
if (Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) < 1.25 * Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime)) // If rhythms are the same.
{ {
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
// Rewarding angles, take the smaller velocity as base.
double angleBonus = Math.Min(currVelocity, prevVelocity);
wideAngleBonus = calcWideAngleBonus(currAngle);
acuteAngleBonus = calcAcuteAngleBonus(currAngle); acuteAngleBonus = calcAcuteAngleBonus(currAngle);
// Penalize angle repetition. // Penalize angle repetition.
wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3))); acuteAngleBonus *= 0.08 + 0.92 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastAngle), 3)));
// Apply full wide angle bonus for distance more than one diameter
wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
// Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
acuteAngleBonus *= angleBonus * acuteAngleBonus *= angleBonus *
DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.StrainTime, 2), 300, 400) * DifficultyCalculationUtils.Smootherstep(DifficultyCalculationUtils.MillisecondsToBPM(osuCurrObj.AdjustedDeltaTime, 2), 300, 400) *
DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2); DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, diameter, diameter * 2);
}
// Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle wideAngleBonus = calcWideAngleBonus(currAngle);
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus // Penalize angle repetition.
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter) wideAngleBonus *= 1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3));
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)) // Apply full wide angle bonus for distance more than one diameter
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter) wideAngleBonus *= angleBonus * DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, 0, diameter);
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60)); // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
// https://www.desmos.com/calculator/dp0v0nvowc
wiggleBonus = angleBonus
* DifficultyCalculationUtils.Smootherstep(osuCurrObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuCurrObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(currAngle, double.DegreesToRadians(110), double.DegreesToRadians(60))
* DifficultyCalculationUtils.Smootherstep(osuLastObj.LazyJumpDistance, radius, diameter)
* Math.Pow(DifficultyCalculationUtils.ReverseLerp(osuLastObj.LazyJumpDistance, diameter * 3, diameter), 1.8)
* DifficultyCalculationUtils.Smootherstep(lastAngle, double.DegreesToRadians(110), double.DegreesToRadians(60));
if (osuLast2Obj != null)
{
// If objects just go back and forth through a middle point - don't give as much wide bonus
// Use Previous(2) and Previous(0) because angles calculation is done prevprev-prev-curr, so any object's angle's center point is always the previous object
var lastBaseObject = (OsuHitObject)osuLastObj.BaseObject;
var last2BaseObject = (OsuHitObject)osuLast2Obj.BaseObject;
float distance = (last2BaseObject.StackedPosition - lastBaseObject.StackedPosition).Length;
if (distance < 1)
{
wideAngleBonus *= 1 - 0.35 * (1 - distance);
}
} }
} }
if (Math.Max(prevVelocity, currVelocity) != 0) if (Math.Max(prevVelocity, currVelocity) != 0)
{ {
// We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities. // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.StrainTime; prevVelocity = (osuLastObj.LazyJumpDistance + osuLastLastObj.TravelDistance) / osuLastObj.AdjustedDeltaTime;
currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.StrainTime; currVelocity = (osuCurrObj.LazyJumpDistance + osuLastObj.TravelDistance) / osuCurrObj.AdjustedDeltaTime;
// Scale with ratio of difference compared to 0.5 * max dist. // Scale with ratio of difference compared to 0.5 * max dist.
double distRatio = Math.Pow(Math.Sin(Math.PI / 2 * Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity)), 2); double distRatio = DifficultyCalculationUtils.Smoothstep(Math.Abs(prevVelocity - currVelocity) / Math.Max(prevVelocity, currVelocity), 0, 1);
// Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing. // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime), Math.Abs(prevVelocity - currVelocity)); double overlapVelocityBuff = Math.Min(diameter * 1.25 / Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), Math.Abs(prevVelocity - currVelocity));
velocityChangeBonus = overlapVelocityBuff * distRatio; velocityChangeBonus = overlapVelocityBuff * distRatio;
// Penalize for rhythm changes. // Penalize for rhythm changes.
velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime) / Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime), 2); velocityChangeBonus *= Math.Pow(Math.Min(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime) / Math.Max(osuCurrObj.AdjustedDeltaTime, osuLastObj.AdjustedDeltaTime), 2);
} }
if (osuLastObj.BaseObject is Slider) if (osuLastObj.BaseObject is Slider)
@@ -131,9 +150,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
} }
aimStrain += wiggleBonus * wiggle_multiplier; aimStrain += wiggleBonus * wiggle_multiplier;
aimStrain += velocityChangeBonus * velocity_change_multiplier;
// Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger. // Add in acute angle bonus or wide angle bonus, whichever is larger.
aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier + velocityChangeBonus * velocity_change_multiplier); aimStrain += Math.Max(acuteAngleBonus * acute_angle_multiplier, wideAngleBonus * wide_angle_multiplier);
// Apply high circle size bonus
aimStrain *= osuCurrObj.SmallCircleBonus;
// Add in additional slider velocity bonus. // Add in additional slider velocity bonus.
if (withSliderTravelDistance) if (withSliderTravelDistance)

View File

@@ -52,7 +52,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var currentObj = (OsuDifficultyHitObject)current.Previous(i); var currentObj = (OsuDifficultyHitObject)current.Previous(i);
var currentHitObject = (OsuHitObject)(currentObj.BaseObject); var currentHitObject = (OsuHitObject)(currentObj.BaseObject);
cumulativeStrainTime += lastObj.StrainTime; cumulativeStrainTime += lastObj.AdjustedDeltaTime;
if (!(currentObj.BaseObject is Spinner)) if (!(currentObj.BaseObject is Spinner))
{ {
@@ -95,7 +95,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (osuCurrent.BaseObject is Slider osuSlider) if (osuCurrent.BaseObject is Slider osuSlider)
{ {
// Invert the scaling factor to determine the true travel distance independent of circle size. // Invert the scaling factor to determine the true travel distance independent of circle size.
double pixelTravelDistance = osuSlider.LazyTravelDistance / scalingFactor; double pixelTravelDistance = osuCurrent.LazyTravelDistance / scalingFactor;
// Reward sliders based on velocity. // Reward sliders based on velocity.
sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5); sliderBonus = Math.Pow(Math.Max(0.0, pixelTravelDistance / osuCurrent.TravelTime - min_velocity), 0.5);

View File

@@ -15,8 +15,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
{ {
private const int history_time_max = 5 * 1000; // 5 seconds private const int history_time_max = 5 * 1000; // 5 seconds
private const int history_objects_max = 32; private const int history_objects_max = 32;
private const double rhythm_overall_multiplier = 0.95; private const double rhythm_overall_multiplier = 1.0;
private const double rhythm_ratio_multiplier = 12.0; private const double rhythm_ratio_multiplier = 15.0;
/// <summary> /// <summary>
/// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>. /// Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current <see cref="OsuDifficultyHitObject"/>.
@@ -26,6 +26,8 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
if (current.BaseObject is Spinner) if (current.BaseObject is Spinner)
return 0; return 0;
var currentOsuObject = (OsuDifficultyHitObject)current;
double rhythmComplexitySum = 0; double rhythmComplexitySum = 0;
double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3; double deltaDifferenceEpsilon = ((OsuDifficultyHitObject)current).HitWindowGreat * 0.3;
@@ -62,22 +64,26 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count. double currHistoricalDecay = Math.Min(noteDecay, timeDecay); // either we're limited by time or limited by object count.
double currDelta = currObj.StrainTime; // Use custom cap value to ensure that that at this point delta time is actually zero
double prevDelta = prevObj.StrainTime; double currDelta = Math.Max(currObj.DeltaTime, 1e-7);
double lastDelta = lastObj.StrainTime; double prevDelta = Math.Max(prevObj.DeltaTime, 1e-7);
double lastDelta = Math.Max(lastObj.DeltaTime, 1e-7);
// calculate how much current delta difference deserves a rhythm bonus // calculate how much current delta difference deserves a rhythm bonus
// this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200) // this function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e 100 and 200)
double deltaDifferenceRatio = Math.Min(prevDelta, currDelta) / Math.Max(prevDelta, currDelta); double deltaDifference = Math.Max(prevDelta, currDelta) / Math.Min(prevDelta, currDelta);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, Math.Pow(Math.Sin(Math.PI / deltaDifferenceRatio), 2));
// Take only the fractional part of the value since we're only interested in punishing multiples
double deltaDifferenceFraction = deltaDifference - Math.Truncate(deltaDifference);
double currRatio = 1.0 + rhythm_ratio_multiplier * Math.Min(0.5, DifficultyCalculationUtils.SmoothstepBellCurve(deltaDifferenceFraction));
// reduce ratio bonus if delta difference is too big // reduce ratio bonus if delta difference is too big
double fraction = Math.Max(prevDelta / currDelta, currDelta / prevDelta); double differenceMultiplier = Math.Clamp(2.0 - deltaDifference / 8.0, 0.0, 1.0);
double fractionMultiplier = Math.Clamp(2.0 - fraction / 8.0, 0.0, 1.0);
double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon); double windowPenalty = Math.Min(1, Math.Max(0, Math.Abs(prevDelta - currDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
double effectiveRatio = windowPenalty * currRatio * fractionMultiplier; double effectiveRatio = windowPenalty * currRatio * differenceMultiplier;
if (firstDeltaSwitch) if (firstDeltaSwitch)
{ {
@@ -170,7 +176,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
prevObj = currObj; prevObj = currObj;
} }
return Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though) double rhythmDifficulty = Math.Sqrt(4 + rhythmComplexitySum * rhythm_overall_multiplier) / 2.0; // produces multiplier that can be applied to strain. range [1, infinity) (not really though)
rhythmDifficulty *= 1 - currentOsuObject.GetDoubletapness((OsuDifficultyHitObject)current.Next(0));
return rhythmDifficulty;
} }
private class Island : IEquatable<Island> private class Island : IEquatable<Island>

View File

@@ -18,7 +18,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers private const double single_spacing_threshold = OsuDifficultyHitObject.NORMALISED_DIAMETER * 1.25; // 1.25 circles distance between centers
private const double min_speed_bonus = 200; // 200 BPM 1/4th private const double min_speed_bonus = 200; // 200 BPM 1/4th
private const double speed_balancing_factor = 40; private const double speed_balancing_factor = 40;
private const double distance_multiplier = 0.9; private const double distance_multiplier = 0.8;
/// <summary> /// <summary>
/// Evaluates the difficulty of tapping the current object, based on: /// Evaluates the difficulty of tapping the current object, based on:
@@ -37,7 +37,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
var osuCurrObj = (OsuDifficultyHitObject)current; var osuCurrObj = (OsuDifficultyHitObject)current;
var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null; var osuPrevObj = current.Index > 0 ? (OsuDifficultyHitObject)current.Previous(0) : null;
double strainTime = osuCurrObj.StrainTime; double strainTime = osuCurrObj.AdjustedDeltaTime;
double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0)); double doubletapness = 1.0 - osuCurrObj.GetDoubletapness((OsuDifficultyHitObject?)osuCurrObj.Next(0));
// Cap deltatime to the OD 300 hitwindow. // Cap deltatime to the OD 300 hitwindow.
@@ -60,6 +60,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Evaluators
// Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier; double distanceBonus = Math.Pow(distance / single_spacing_threshold, 3.95) * distance_multiplier;
// Apply reduced small circle bonus because flow aim difficulty on small circles doesn't scale as hard as jumps
distanceBonus *= Math.Sqrt(osuCurrObj.SmallCircleBonus);
if (mods.OfType<OsuModAutopilot>().Any()) if (mods.OfType<OsuModAutopilot>().Any())
distanceBonus = 0; distanceBonus = 0;

View File

@@ -53,12 +53,37 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("slider_factor")] [JsonProperty("slider_factor")]
public double SliderFactor { get; set; } public double SliderFactor { get; set; }
/// <summary>
/// Describes how much of <see cref="AimDifficultStrainCount"/> is contributed to by hitcircles or sliders
/// A value closer to 0.0 indicates most of <see cref="AimDifficultStrainCount"/> is contributed by hitcircles
/// A value closer to Infinity indicates most of <see cref="AimDifficultStrainCount"/> is contributed by sliders
/// </summary>
[JsonProperty("aim_top_weighted_slider_factor")]
public double AimTopWeightedSliderFactor { get; set; }
/// <summary>
/// Describes how much of <see cref="SpeedDifficultStrainCount"/> is contributed to by hitcircles or sliders
/// A value closer to 0.0 indicates most of <see cref="SpeedDifficultStrainCount"/> is contributed by hitcircles
/// A value closer to Infinity indicates most of <see cref="SpeedDifficultStrainCount"/> is contributed by sliders
/// </summary>
[JsonProperty("speed_top_weighted_slider_factor")]
public double SpeedTopWeightedSliderFactor { get; set; }
[JsonProperty("aim_difficult_strain_count")] [JsonProperty("aim_difficult_strain_count")]
public double AimDifficultStrainCount { get; set; } public double AimDifficultStrainCount { get; set; }
[JsonProperty("speed_difficult_strain_count")] [JsonProperty("speed_difficult_strain_count")]
public double SpeedDifficultStrainCount { get; set; } public double SpeedDifficultStrainCount { get; set; }
[JsonProperty("nested_score_per_object")]
public double NestedScorePerObject { get; set; }
[JsonProperty("legacy_score_base_multiplier")]
public double LegacyScoreBaseMultiplier { get; set; }
[JsonProperty("maximum_legacy_combo_score")]
public double MaximumLegacyComboScore { get; set; }
/// <summary> /// <summary>
/// The beatmap's drain rate. This doesn't scale with rate-adjusting mods. /// The beatmap's drain rate. This doesn't scale with rate-adjusting mods.
/// </summary> /// </summary>
@@ -97,6 +122,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount); yield return (ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT, SpeedDifficultStrainCount);
yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount); yield return (ATTRIB_ID_SPEED_NOTE_COUNT, SpeedNoteCount);
yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount); yield return (ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT, AimDifficultSliderCount);
yield return (ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR, AimTopWeightedSliderFactor);
yield return (ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR, SpeedTopWeightedSliderFactor);
yield return (ATTRIB_ID_NESTED_SCORE_PER_OBJECT, NestedScorePerObject);
yield return (ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER, LegacyScoreBaseMultiplier);
yield return (ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE, MaximumLegacyComboScore);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -112,6 +142,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT]; SpeedDifficultStrainCount = values[ATTRIB_ID_SPEED_DIFFICULT_STRAIN_COUNT];
SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT]; SpeedNoteCount = values[ATTRIB_ID_SPEED_NOTE_COUNT];
AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT]; AimDifficultSliderCount = values[ATTRIB_ID_AIM_DIFFICULT_SLIDER_COUNT];
AimTopWeightedSliderFactor = values[ATTRIB_ID_AIM_TOP_WEIGHTED_SLIDER_FACTOR];
SpeedTopWeightedSliderFactor = values[ATTRIB_ID_SPEED_TOP_WEIGHTED_SLIDER_FACTOR];
NestedScorePerObject = values[ATTRIB_ID_NESTED_SCORE_PER_OBJECT];
LegacyScoreBaseMultiplier = values[ATTRIB_ID_LEGACY_SCORE_BASE_MULTIPLIER];
MaximumLegacyComboScore = values[ATTRIB_ID_MAXIMUM_LEGACY_COMBO_SCORE];
DrainRate = onlineInfo.DrainRate; DrainRate = onlineInfo.DrainRate;
HitCircleCount = onlineInfo.CircleCount; HitCircleCount = onlineInfo.CircleCount;
SliderCount = onlineInfo.SliderCount; SliderCount = onlineInfo.SliderCount;

View File

@@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
#nullable disable
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -13,69 +11,103 @@ using osu.Game.Rulesets.Difficulty.Skills;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuDifficultyCalculator : DifficultyCalculator public class OsuDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 0.0675; private const double star_rating_multiplier = 0.0265;
public override int Version => 20250306; public override int Version => 20251020;
public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public OsuDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
{ {
} }
public static double CalculateRateAdjustedApproachRate(double approachRate, double clockRate)
{
double preempt = IBeatmapDifficultyInfo.DifficultyRange(approachRate, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN) / clockRate;
return IBeatmapDifficultyInfo.InverseDifficultyRange(preempt, OsuHitObject.PREEMPT_MAX, OsuHitObject.PREEMPT_MID, OsuHitObject.PREEMPT_MIN);
}
public static double CalculateRateAdjustedOverallDifficulty(double overallDifficulty, double clockRate)
{
HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(overallDifficulty);
double hitWindowGreat = hitWindows.WindowFor(HitResult.Great) / clockRate;
return (79.5 - hitWindowGreat) / 6;
}
protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate) protected override DifficultyAttributes CreateDifficultyAttributes(IBeatmap beatmap, Mod[] mods, Skill[] skills, double clockRate)
{ {
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new OsuDifficultyAttributes { Mods = mods }; return new OsuDifficultyAttributes { Mods = mods };
var aim = skills.OfType<Aim>().Single(a => a.IncludeSliders); var aim = skills.OfType<Aim>().Single(a => a.IncludeSliders);
double aimRating = Math.Sqrt(aim.DifficultyValue()) * difficulty_multiplier; var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders);
double aimDifficultyStrainCount = aim.CountTopWeightedStrains(); var speed = skills.OfType<Speed>().Single();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault();
double speedNotes = speed.RelevantNoteCount();
double aimDifficultStrainCount = aim.CountTopWeightedStrains();
double speedDifficultStrainCount = speed.CountTopWeightedStrains();
double aimNoSlidersTopWeightedSliderCount = aimWithoutSliders.CountTopWeightedSliders();
double aimNoSlidersDifficultStrainCount = aimWithoutSliders.CountTopWeightedStrains();
double aimTopWeightedSliderFactor = aimNoSlidersTopWeightedSliderCount / Math.Max(1, aimNoSlidersDifficultStrainCount - aimNoSlidersTopWeightedSliderCount);
double speedTopWeightedSliderCount = speed.CountTopWeightedSliders();
double speedTopWeightedSliderFactor = speedTopWeightedSliderCount / Math.Max(1, speedDifficultStrainCount - speedTopWeightedSliderCount);
double difficultSliders = aim.GetDifficultSliders(); double difficultSliders = aim.GetDifficultSliders();
var aimWithoutSliders = skills.OfType<Aim>().Single(a => !a.IncludeSliders); double approachRate = CalculateRateAdjustedApproachRate(beatmap.Difficulty.ApproachRate, clockRate);
double aimRatingNoSliders = Math.Sqrt(aimWithoutSliders.DifficultyValue()) * difficulty_multiplier; double overallDifficulty = CalculateRateAdjustedOverallDifficulty(beatmap.Difficulty.OverallDifficulty, clockRate);
double sliderFactor = aimRating > 0 ? aimRatingNoSliders / aimRating : 1;
var speed = skills.OfType<Speed>().Single(); int hitCircleCount = beatmap.HitObjects.Count(h => h is HitCircle);
double speedRating = Math.Sqrt(speed.DifficultyValue()) * difficulty_multiplier; int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
double speedNotes = speed.RelevantNoteCount(); int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
double speedDifficultyStrainCount = speed.CountTopWeightedStrains();
var flashlight = skills.OfType<Flashlight>().SingleOrDefault(); int totalHits = beatmap.HitObjects.Count;
double flashlightRating = flashlight == null ? 0.0 : Math.Sqrt(flashlight.DifficultyValue()) * difficulty_multiplier;
if (mods.Any(m => m is OsuModTouchDevice)) double drainRate = beatmap.Difficulty.DrainRate;
{
aimRating = Math.Pow(aimRating, 0.8);
flashlightRating = Math.Pow(flashlightRating, 0.8);
}
if (mods.Any(h => h is OsuModRelax)) double aimDifficultyValue = aim.DifficultyValue();
{ double aimNoSlidersDifficultyValue = aimWithoutSliders.DifficultyValue();
aimRating *= 0.9; double speedDifficultyValue = speed.DifficultyValue();
speedRating = 0.0;
flashlightRating *= 0.7; double mechanicalDifficultyRating = calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
} double sliderFactor = aimDifficultyValue > 0 ? OsuRatingCalculator.CalculateDifficultyRating(aimNoSlidersDifficultyValue) / OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue) : 1;
else if (mods.Any(h => h is OsuModAutopilot))
{ var osuRatingCalculator = new OsuRatingCalculator(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor);
speedRating *= 0.5;
aimRating = 0.0; double aimRating = osuRatingCalculator.ComputeAimRating(aimDifficultyValue);
flashlightRating *= 0.4; double speedRating = osuRatingCalculator.ComputeSpeedRating(speedDifficultyValue);
}
double flashlightRating = 0.0;
if (flashlight is not null)
flashlightRating = osuRatingCalculator.ComputeFlashlightRating(flashlight.DifficultyValue());
double sliderNestedScorePerObject = LegacyScoreUtils.CalculateNestedScorePerObject(beatmap, totalHits);
double legacyScoreBaseMultiplier = LegacyScoreUtils.CalculateDifficultyPeppyStars(beatmap);
var simulator = new OsuLegacyScoreSimulator();
var scoreAttributes = simulator.Simulate(WorkingBeatmap, beatmap);
double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating); double baseAimPerformance = OsuStrainSkill.DifficultyToPerformance(aimRating);
double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating); double baseSpeedPerformance = OsuStrainSkill.DifficultyToPerformance(speedRating);
double baseFlashlightPerformance = 0.0; double baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
if (mods.Any(h => h is OsuModFlashlight))
baseFlashlightPerformance = Flashlight.DifficultyToPerformance(flashlightRating);
double basePerformance = double basePerformance =
Math.Pow( Math.Pow(
@@ -84,15 +116,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1 Math.Pow(baseFlashlightPerformance, 1.1), 1.0 / 1.1
); );
double starRating = basePerformance > 0.00001 double starRating = calculateStarRating(basePerformance);
? Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * 0.027 * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4)
: 0;
double drainRate = beatmap.Difficulty.DrainRate;
int hitCirclesCount = beatmap.HitObjects.Count(h => h is HitCircle);
int sliderCount = beatmap.HitObjects.Count(h => h is Slider);
int spinnerCount = beatmap.HitObjects.Count(h => h is Spinner);
OsuDifficultyAttributes attributes = new OsuDifficultyAttributes OsuDifficultyAttributes attributes = new OsuDifficultyAttributes
{ {
@@ -104,18 +128,41 @@ namespace osu.Game.Rulesets.Osu.Difficulty
SpeedNoteCount = speedNotes, SpeedNoteCount = speedNotes,
FlashlightDifficulty = flashlightRating, FlashlightDifficulty = flashlightRating,
SliderFactor = sliderFactor, SliderFactor = sliderFactor,
AimDifficultStrainCount = aimDifficultyStrainCount, AimDifficultStrainCount = aimDifficultStrainCount,
SpeedDifficultStrainCount = speedDifficultyStrainCount, SpeedDifficultStrainCount = speedDifficultStrainCount,
AimTopWeightedSliderFactor = aimTopWeightedSliderFactor,
SpeedTopWeightedSliderFactor = speedTopWeightedSliderFactor,
DrainRate = drainRate, DrainRate = drainRate,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
HitCircleCount = hitCirclesCount, HitCircleCount = hitCircleCount,
SliderCount = sliderCount, SliderCount = sliderCount,
SpinnerCount = spinnerCount, SpinnerCount = spinnerCount,
NestedScorePerObject = sliderNestedScorePerObject,
LegacyScoreBaseMultiplier = legacyScoreBaseMultiplier,
MaximumLegacyComboScore = scoreAttributes.ComboScore
}; };
return attributes; return attributes;
} }
private double calculateMechanicalDifficultyRating(double aimDifficultyValue, double speedDifficultyValue)
{
double aimValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(aimDifficultyValue));
double speedValue = OsuStrainSkill.DifficultyToPerformance(OsuRatingCalculator.CalculateDifficultyRating(speedDifficultyValue));
double totalValue = Math.Pow(Math.Pow(aimValue, 1.1) + Math.Pow(speedValue, 1.1), 1 / 1.1);
return calculateStarRating(totalValue);
}
private double calculateStarRating(double basePerformance)
{
if (basePerformance <= 0.00001)
return 0;
return Math.Cbrt(OsuPerformanceCalculator.PERFORMANCE_BASE_MULTIPLIER) * star_rating_multiplier * (Math.Cbrt(100000 / Math.Pow(2, 1 / 1.1) * basePerformance) + 4);
}
protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate) protected override IEnumerable<DifficultyHitObject> CreateDifficultyHitObjects(IBeatmap beatmap, double clockRate)
{ {
List<DifficultyHitObject> objects = new List<DifficultyHitObject>(); List<DifficultyHitObject> objects = new List<DifficultyHitObject>();
@@ -124,8 +171,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// If the map has less than two OsuHitObjects, the enumerator will not return anything. // If the map has less than two OsuHitObjects, the enumerator will not return anything.
for (int i = 1; i < beatmap.HitObjects.Count; i++) for (int i = 1; i < beatmap.HitObjects.Count; i++)
{ {
var lastLast = i > 1 ? beatmap.HitObjects[i - 2] : null; objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], clockRate, objects, objects.Count));
objects.Add(new OsuDifficultyHitObject(beatmap.HitObjects[i], beatmap.HitObjects[i - 1], lastLast, clockRate, objects, objects.Count));
} }
return objects; return objects;
@@ -154,7 +200,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
new OsuModEasy(), new OsuModEasy(),
new OsuModHardRock(), new OsuModHardRock(),
new OsuModFlashlight(), new OsuModFlashlight(),
new MultiMod(new OsuModFlashlight(), new OsuModHidden()) new OsuModHidden(),
}; };
} }
} }

View File

@@ -0,0 +1,200 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuLegacyScoreMissCalculator
{
private readonly ScoreInfo score;
private readonly OsuDifficultyAttributes attributes;
public OsuLegacyScoreMissCalculator(ScoreInfo scoreInfo, OsuDifficultyAttributes attributes)
{
score = scoreInfo;
this.attributes = attributes;
}
public double Calculate()
{
if (attributes.MaxCombo == 0 || score.LegacyTotalScore == null)
return 0;
double scoreV1Multiplier = attributes.LegacyScoreBaseMultiplier * getLegacyScoreMultiplier();
double relevantComboPerObject = calculateRelevantScoreComboPerObject();
double maximumMissCount = calculateMaximumComboBasedMissCount();
double scoreObtainedDuringMaxCombo = calculateScoreAtCombo(score.MaxCombo, relevantComboPerObject, scoreV1Multiplier);
double remainingScore = score.LegacyTotalScore.Value - scoreObtainedDuringMaxCombo;
if (remainingScore <= 0)
return maximumMissCount;
double remainingCombo = attributes.MaxCombo - score.MaxCombo;
double expectedRemainingScore = calculateScoreAtCombo(remainingCombo, relevantComboPerObject, scoreV1Multiplier);
double scoreBasedMissCount = expectedRemainingScore / remainingScore;
// If there's less then one miss detected - let combo-based miss count decide if this is FC or not
scoreBasedMissCount = Math.Max(scoreBasedMissCount, 1);
// Cap result by very harsh version of combo-based miss count
return Math.Min(scoreBasedMissCount, maximumMissCount);
}
/// <summary>
/// Calculates the amount of score that would be achieved at a given combo.
/// </summary>
private double calculateScoreAtCombo(double combo, double relevantComboPerObject, double scoreV1Multiplier)
{
int countGreat = score.Statistics.GetValueOrDefault(HitResult.Great);
int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
int totalHits = countGreat + countOk + countMeh + countMiss;
double estimatedObjects = combo / relevantComboPerObject - 1;
// The combo portion of ScoreV1 follows arithmetic progression
// Therefore, we calculate the combo portion of score using the combo per object and our current combo.
double comboScore = relevantComboPerObject > 0 ? (2 * (relevantComboPerObject - 1) + (estimatedObjects - 1) * relevantComboPerObject) * estimatedObjects / 2 : 0;
// We then apply the accuracy and ScoreV1 multipliers to the resulting score.
comboScore *= score.Accuracy * 300 / 25 * scoreV1Multiplier;
double objectsHit = (totalHits - countMiss) * combo / attributes.MaxCombo;
// Score also has a non-combo portion we need to create the final score value.
double nonComboScore = (300 + attributes.NestedScorePerObject) * score.Accuracy * objectsHit;
return comboScore + nonComboScore;
}
/// <summary>
/// Calculates the relevant combo per object for legacy score.
/// This assumes a uniform distribution for circles and sliders.
/// This handles cases where objects (such as buzz sliders) do not fit a normal arithmetic progression model.
/// </summary>
private double calculateRelevantScoreComboPerObject()
{
double comboScore = attributes.MaximumLegacyComboScore;
// We then reverse apply the ScoreV1 multipliers to get the raw value.
comboScore /= 300.0 / 25.0 * attributes.LegacyScoreBaseMultiplier;
// Reverse the arithmetic progression to work out the amount of combo per object based on the score.
double result = (attributes.MaxCombo - 2) * attributes.MaxCombo;
result /= Math.Max(attributes.MaxCombo + 2 * (comboScore - 1), 1);
return result;
}
/// <summary>
/// This function is a harsher version of current combo-based miss count, used to provide reasonable value for cases where score-based miss count can't do this.
/// </summary>
private double calculateMaximumComboBasedMissCount()
{
int countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
if (attributes.SliderCount <= 0)
return countMiss;
int countOk = score.Statistics.GetValueOrDefault(HitResult.Ok);
int countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
int totalImperfectHits = countOk + countMeh + countMiss;
double missCount = 0;
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (score.MaxCombo < fullComboThreshold)
missCount = Math.Pow(fullComboThreshold / Math.Max(1.0, score.MaxCombo), 2.5);
// In classic scores there can't be more misses than a sum of all non-perfect judgements
missCount = Math.Min(missCount, totalImperfectHits);
// Every slider has *at least* 2 combo attributed in classic mechanics.
// If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end)
// Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break.
// It must have been a slider end.
int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - score.MaxCombo) / 2);
int scoreMissCount = score.Statistics.GetValueOrDefault(HitResult.Miss);
double sliderBreaks = missCount - scoreMissCount;
if (sliderBreaks > maxPossibleSliderBreaks)
missCount = scoreMissCount + maxPossibleSliderBreaks;
return missCount;
}
/// <remarks>
/// Logic copied from <see cref="OsuLegacyScoreSimulator.GetLegacyScoreMultiplier"/>.
/// </remarks>
private double getLegacyScoreMultiplier()
{
bool scoreV2 = score.Mods.Any(m => m is ModScoreV2);
double multiplier = 1.0;
foreach (var mod in score.Mods)
{
switch (mod)
{
case OsuModNoFail:
multiplier *= scoreV2 ? 1.0 : 0.5;
break;
case OsuModEasy:
multiplier *= 0.5;
break;
case OsuModHalfTime:
case OsuModDaycore:
multiplier *= 0.3;
break;
case OsuModHidden:
multiplier *= 1.06;
break;
case OsuModHardRock:
multiplier *= scoreV2 ? 1.10 : 1.06;
break;
case OsuModDoubleTime:
case OsuModNightcore:
multiplier *= scoreV2 ? 1.20 : 1.12;
break;
case OsuModFlashlight:
multiplier *= 1.12;
break;
case OsuModSpunOut:
multiplier *= 0.9;
break;
case OsuModRelax:
case OsuModAutopilot:
return 0;
}
}
return multiplier;
}
}
}

View File

@@ -27,6 +27,18 @@ namespace osu.Game.Rulesets.Osu.Difficulty
[JsonProperty("speed_deviation")] [JsonProperty("speed_deviation")]
public double? SpeedDeviation { get; set; } public double? SpeedDeviation { get; set; }
[JsonProperty("combo_based_estimated_miss_count")]
public double ComboBasedEstimatedMissCount { get; set; }
[JsonProperty("score_based_estimated_miss_count")]
public double? ScoreBasedEstimatedMissCount { get; set; }
[JsonProperty("aim_estimated_slider_breaks")]
public double AimEstimatedSliderBreaks { get; set; }
[JsonProperty("speed_estimated_slider_breaks")]
public double SpeedEstimatedSliderBreaks { get; set; }
public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay() public override IEnumerable<PerformanceDisplayAttribute> GetAttributesForDisplay()
{ {
foreach (var attribute in base.GetAttributesForDisplay()) foreach (var attribute in base.GetAttributesForDisplay())

View File

@@ -4,25 +4,25 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu.Difficulty.Skills; using osu.Game.Rulesets.Osu.Difficulty.Skills;
using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Scoring;
using osu.Game.Rulesets.Scoring; using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty namespace osu.Game.Rulesets.Osu.Difficulty
{ {
public class OsuPerformanceCalculator : PerformanceCalculator public class OsuPerformanceCalculator : PerformanceCalculator
{ {
public const double PERFORMANCE_BASE_MULTIPLIER = 1.15; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things. public const double PERFORMANCE_BASE_MULTIPLIER = 1.14; // This is being adjusted to keep the final pp value scaled around what it used to be when changing things.
private bool usingClassicSliderAccuracy; private bool usingClassicSliderAccuracy;
private bool usingScoreV2;
private double accuracy; private double accuracy;
private int scoreMaxCombo; private int scoreMaxCombo;
@@ -55,6 +55,9 @@ namespace osu.Game.Rulesets.Osu.Difficulty
private double? speedDeviation; private double? speedDeviation;
private double aimEstimatedSliderBreaks;
private double speedEstimatedSliderBreaks;
public OsuPerformanceCalculator() public OsuPerformanceCalculator()
: base(new OsuRuleset()) : base(new OsuRuleset())
{ {
@@ -65,6 +68,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
var osuAttributes = (OsuDifficultyAttributes)attributes; var osuAttributes = (OsuDifficultyAttributes)attributes;
usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value); usingClassicSliderAccuracy = score.Mods.OfType<OsuModClassic>().Any(m => m.NoSliderHeadAccuracy.Value);
usingScoreV2 = score.Mods.Any(m => m is ModScoreV2);
accuracy = score.Accuracy; accuracy = score.Accuracy;
scoreMaxCombo = score.MaxCombo; scoreMaxCombo = score.MaxCombo;
@@ -80,9 +84,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty)); score.Mods.OfType<IApplicableToDifficulty>().ForEach(m => m.ApplyToDifficulty(difficulty));
var track = new TrackVirtual(10000); clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
clockRate = track.Rate;
HitWindows hitWindows = new OsuHitWindows(); HitWindows hitWindows = new OsuHitWindows();
hitWindows.SetDifficulty(difficulty.OverallDifficulty); hitWindows.SetDifficulty(difficulty.OverallDifficulty);
@@ -91,35 +93,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate; okHitWindow = hitWindows.WindowFor(HitResult.Ok) / clockRate;
mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate; mehHitWindow = hitWindows.WindowFor(HitResult.Meh) / clockRate;
double preempt = IBeatmapDifficultyInfo.DifficultyRange(difficulty.ApproachRate, 1800, 1200, 450) / clockRate; approachRate = OsuDifficultyCalculator.CalculateRateAdjustedApproachRate(difficulty.ApproachRate, clockRate);
overallDifficulty = OsuDifficultyCalculator.CalculateRateAdjustedOverallDifficulty(difficulty.OverallDifficulty, clockRate);
overallDifficulty = (79.5 - greatHitWindow) / 6; double comboBasedEstimatedMissCount = calculateComboBasedEstimatedMissCount(osuAttributes);
approachRate = preempt > 1200 ? (1800 - preempt) / 120 : (1200 - preempt) / 150 + 5; double? scoreBasedEstimatedMissCount = null;
if (osuAttributes.SliderCount > 0) if (usingClassicSliderAccuracy && score.LegacyTotalScore != null)
{ {
if (usingClassicSliderAccuracy) var legacyScoreMissCalculator = new OsuLegacyScoreMissCalculator(score, osuAttributes);
{ scoreBasedEstimatedMissCount = legacyScoreMissCalculator.Calculate();
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * osuAttributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold) effectiveMissCount = scoreBasedEstimatedMissCount.Value;
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo); }
else
// In classic scores there can't be more misses than a sum of all non-perfect judgements {
effectiveMissCount = Math.Min(effectiveMissCount, totalImperfectHits); // Use combo-based miss count if this isn't a legacy score
} effectiveMissCount = comboBasedEstimatedMissCount;
else
{
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
if (scoreMaxCombo < fullComboThreshold)
effectiveMissCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// Combine regular misses with tick misses since tick misses break combo as well
effectiveMissCount = Math.Min(effectiveMissCount, countSliderTickMiss + countMiss);
}
} }
effectiveMissCount = Math.Max(countMiss, effectiveMissCount); effectiveMissCount = Math.Max(countMiss, effectiveMissCount);
@@ -135,10 +125,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(h => h is OsuModRelax)) if (score.Mods.Any(h => h is OsuModRelax))
{ {
// https://www.desmos.com/calculator/bc9eybdthb // https://www.desmos.com/calculator/vspzsop6td
// we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0 // we use OD13.3 as maximum since it's the value at which great hitwidow becomes 0
// this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11) // this is well beyond currently maximum achievable OD which is 12.17 (DTx2 + DA with OD11)
double okMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 1.8) : 1.0); double okMultiplier = 0.75 * Math.Max(0.0, overallDifficulty > 0.0 ? 1 - overallDifficulty / 13.33 : 1.0);
double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0); double mehMultiplier = Math.Max(0.0, overallDifficulty > 0.0 ? 1 - Math.Pow(overallDifficulty / 13.33, 5) : 1.0);
// As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it. // As we're adding Oks and Mehs to an approximated number of combo breaks the result can be higher than total hits in specific scenarios (which breaks some calculations) so we need to clamp it.
@@ -167,6 +157,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
Accuracy = accuracyValue, Accuracy = accuracyValue,
Flashlight = flashlightValue, Flashlight = flashlightValue,
EffectiveMissCount = effectiveMissCount, EffectiveMissCount = effectiveMissCount,
ComboBasedEstimatedMissCount = comboBasedEstimatedMissCount,
ScoreBasedEstimatedMissCount = scoreBasedEstimatedMissCount,
AimEstimatedSliderBreaks = aimEstimatedSliderBreaks,
SpeedEstimatedSliderBreaks = speedEstimatedSliderBreaks,
SpeedDeviation = speedDeviation, SpeedDeviation = speedDeviation,
Total = totalValue Total = totalValue
}; };
@@ -207,30 +201,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
aimValue *= lengthBonus; aimValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
aimValue *= calculateMissPenalty(effectiveMissCount, attributes.AimDifficultStrainCount); {
aimEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.AimTopWeightedSliderFactor, attributes);
double approachRateFactor = 0.0; double relevantMissCount = Math.Min(effectiveMissCount + aimEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (score.Mods.Any(h => h is OsuModRelax)) aimValue *= calculateMissPenalty(relevantMissCount, attributes.AimDifficultStrainCount);
approachRateFactor = 0.0; }
aimValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate); aimValue *= 1.3 + (totalHits * (0.0016 / (1 + 2 * effectiveMissCount)) * Math.Pow(accuracy, 16)) * (1 - 0.003 * attributes.DrainRate * attributes.DrainRate);
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. aimValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate, sliderFactor: attributes.SliderFactor);
aimValue *= 1.0 + 0.04 * (12.0 - approachRate);
} }
aimValue *= accuracy; aimValue *= accuracy;
// It is important to consider accuracy difficulty when scaling with accuracy.
aimValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return aimValue; return aimValue;
} }
@@ -247,26 +234,23 @@ namespace osu.Game.Rulesets.Osu.Difficulty
speedValue *= lengthBonus; speedValue *= lengthBonus;
if (effectiveMissCount > 0) if (effectiveMissCount > 0)
speedValue *= calculateMissPenalty(effectiveMissCount, attributes.SpeedDifficultStrainCount); {
speedEstimatedSliderBreaks = calculateEstimatedSliderBreaks(attributes.SpeedTopWeightedSliderFactor, attributes);
double approachRateFactor = 0.0; double relevantMissCount = Math.Min(effectiveMissCount + speedEstimatedSliderBreaks, totalImperfectHits + countSliderTickMiss);
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (score.Mods.Any(h => h is OsuModAutopilot)) speedValue *= calculateMissPenalty(relevantMissCount, attributes.SpeedDifficultStrainCount);
approachRateFactor = 0.0; }
speedValue *= 1.0 + approachRateFactor * lengthBonus; // Buff for longer maps with high AR.
// TC bonuses are excluded when blinds is present as the increased visual difficulty is unimportant when notes cannot be seen.
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
{ {
// Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given. // Increasing the speed value by object count for Blinds isn't ideal, so the minimum buff is given.
speedValue *= 1.12; speedValue *= 1.12;
} }
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModTraceable))
{ {
// We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR. speedValue *= 1.0 + OsuRatingCalculator.CalculateVisibilityBonus(score.Mods, approachRate);
speedValue *= 1.0 + 0.04 * (12.0 - approachRate);
} }
double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes); double speedHighDeviationMultiplier = calculateSpeedHighDeviationNerf(attributes);
@@ -280,7 +264,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0); double relevantAccuracy = attributes.SpeedNoteCount == 0 ? 0 : (relevantCountGreat * 6.0 + relevantCountOk * 2.0 + relevantCountMeh) / (attributes.SpeedNoteCount * 6.0);
// Scale the speed value with accuracy and OD. // Scale the speed value with accuracy and OD.
speedValue *= (0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750) * Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2); speedValue *= Math.Pow((accuracy + relevantAccuracy) / 2.0, (14.5 - overallDifficulty) / 2);
return speedValue; return speedValue;
} }
@@ -293,7 +277,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
// This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window. // This percentage only considers HitCircles of any value - in this part of the calculation we focus on hitting the timing hit window.
double betterAccuracyPercentage; double betterAccuracyPercentage;
int amountHitObjectsWithAccuracy = attributes.HitCircleCount; int amountHitObjectsWithAccuracy = attributes.HitCircleCount;
if (!usingClassicSliderAccuracy) if (!usingClassicSliderAccuracy || usingScoreV2)
amountHitObjectsWithAccuracy += attributes.SliderCount; amountHitObjectsWithAccuracy += attributes.SliderCount;
if (amountHitObjectsWithAccuracy > 0) if (amountHitObjectsWithAccuracy > 0)
@@ -316,7 +300,10 @@ namespace osu.Game.Rulesets.Osu.Difficulty
if (score.Mods.Any(m => m is OsuModBlinds)) if (score.Mods.Any(m => m is OsuModBlinds))
accuracyValue *= 1.14; accuracyValue *= 1.14;
else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable)) else if (score.Mods.Any(m => m is OsuModHidden || m is OsuModTraceable))
accuracyValue *= 1.08; {
// Decrease bonus for AR > 10
accuracyValue *= 1 + 0.08 * DifficultyCalculationUtils.ReverseLerp(approachRate, 11.5, 10);
}
if (score.Mods.Any(m => m is OsuModFlashlight)) if (score.Mods.Any(m => m is OsuModFlashlight))
accuracyValue *= 1.02; accuracyValue *= 1.02;
@@ -337,18 +324,73 @@ namespace osu.Game.Rulesets.Osu.Difficulty
flashlightValue *= getComboScalingFactor(attributes); flashlightValue *= getComboScalingFactor(attributes);
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
flashlightValue *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// Scale the flashlight value with accuracy _slightly_. // Scale the flashlight value with accuracy _slightly_.
flashlightValue *= 0.5 + accuracy / 2.0; flashlightValue *= 0.5 + accuracy / 2.0;
// It is important to also consider accuracy difficulty when doing that.
flashlightValue *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return flashlightValue; return flashlightValue;
} }
private double calculateComboBasedEstimatedMissCount(OsuDifficultyAttributes attributes)
{
if (attributes.SliderCount <= 0)
return countMiss;
double missCount = countMiss;
if (usingClassicSliderAccuracy)
{
// Consider that full combo is maximum combo minus dropped slider tails since they don't contribute to combo but also don't break it
// In classic scores we can't know the amount of dropped sliders so we estimate to 10% of all sliders on the map
double fullComboThreshold = attributes.MaxCombo - 0.1 * attributes.SliderCount;
if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// In classic scores there can't be more misses than a sum of all non-perfect judgements
missCount = Math.Min(missCount, totalImperfectHits);
// Every slider has *at least* 2 combo attributed in classic mechanics.
// If they broke on a slider with a tick, then this still works since they would have lost at least 2 combo (the tick and the end)
// Using this as a max means a score that loses 1 combo on a map can't possibly have been a slider break.
// It must have been a slider end.
int maxPossibleSliderBreaks = Math.Min(attributes.SliderCount, (attributes.MaxCombo - scoreMaxCombo) / 2);
double sliderBreaks = missCount - countMiss;
if (sliderBreaks > maxPossibleSliderBreaks)
missCount = countMiss + maxPossibleSliderBreaks;
}
else
{
double fullComboThreshold = attributes.MaxCombo - countSliderEndsDropped;
if (scoreMaxCombo < fullComboThreshold)
missCount = fullComboThreshold / Math.Max(1.0, scoreMaxCombo);
// Combine regular misses with tick misses since tick misses break combo as well
missCount = Math.Min(missCount, countSliderTickMiss + countMiss);
}
return missCount;
}
private double calculateEstimatedSliderBreaks(double topWeightedSliderFactor, OsuDifficultyAttributes attributes)
{
if (!usingClassicSliderAccuracy || countOk == 0)
return 0;
double missedComboPercent = 1.0 - (double)scoreMaxCombo / attributes.MaxCombo;
double estimatedSliderBreaks = Math.Min(countOk, effectiveMissCount * topWeightedSliderFactor);
// Scores with more Oks are more likely to have slider breaks.
double okAdjustment = ((countOk - estimatedSliderBreaks) + 0.5) / countOk;
// There is a low probability of extra slider breaks on effective miss counts close to 1, as score based calculations are good at indicating if only a single break occurred.
estimatedSliderBreaks *= DifficultyCalculationUtils.Smoothstep(effectiveMissCount, 1, 2);
return estimatedSliderBreaks * okAdjustment * DifficultyCalculationUtils.Logistic(missedComboPercent, 0.33, 15);
}
/// <summary> /// <summary>
/// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case. /// Estimates player's deviation on speed notes using <see cref="calculateDeviation"/>, assuming worst-case.
/// Treats all speed notes as hit circles. /// Treats all speed notes as hit circles.
@@ -368,7 +410,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty
double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh); double relevantCountOk = Math.Min(countOk, speedNoteCount - relevantCountMiss - relevantCountMeh);
double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk); double relevantCountGreat = Math.Max(0, speedNoteCount - relevantCountMiss - relevantCountMeh - relevantCountOk);
return calculateDeviation(attributes, relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss); return calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh);
} }
/// <summary> /// <summary>
@@ -377,45 +419,45 @@ namespace osu.Game.Rulesets.Osu.Difficulty
/// will always return the same deviation. Misses are ignored because they are usually due to misaiming. /// will always return the same deviation. Misses are ignored because they are usually due to misaiming.
/// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution. /// Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
/// </summary> /// </summary>
private double? calculateDeviation(OsuDifficultyAttributes attributes, double relevantCountGreat, double relevantCountOk, double relevantCountMeh, double relevantCountMiss) private double? calculateDeviation(double relevantCountGreat, double relevantCountOk, double relevantCountMeh)
{ {
if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0)
return null; return null;
double objectCount = relevantCountGreat + relevantCountOk + relevantCountMeh + relevantCountMiss; // The sample proportion of successful hits.
double n = Math.Max(1, relevantCountGreat + relevantCountOk);
// The probability that a player hits a circle is unknown, but we can estimate it to be
// the number of greats on circles divided by the number of circles, and then add one
// to the number of circles as a bias correction.
double n = Math.Max(1, objectCount - relevantCountMiss - relevantCountMeh);
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
// Proportion of greats hit on circles, ignoring misses and 50s.
double p = relevantCountGreat / n; double p = relevantCountGreat / n;
// We can be 99% confident that p is at least this value. // 99% critical value for the normal distribution (one-tailed).
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); const double z = 2.32634787404;
// Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed. // We can be 99% confident that the population proportion is at least this value.
// Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than: double pLowerBound = Math.Min(p, (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4));
double deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound));
double randomValue = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2)) double deviation;
/ (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation)));
deviation *= Math.Sqrt(1 - randomValue); // Tested max precision for the deviation calculation.
if (pLowerBound > 0.01)
{
// Compute deviation assuming greats and oks are normally distributed.
deviation = greatHitWindow / (Math.Sqrt(2) * DifficultyCalculationUtils.ErfInv(pLowerBound));
// Value deviation approach as greatCount approaches 0 // Subtract the deviation provided by tails that land outside the ok hit window from the deviation computed above.
double limitValue = okHitWindow / Math.Sqrt(3); // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow.
double okHitWindowTailAmount = Math.Sqrt(2 / Math.PI) * okHitWindow * Math.Exp(-0.5 * Math.Pow(okHitWindow / deviation, 2))
/ (deviation * DifficultyCalculationUtils.Erf(okHitWindow / (Math.Sqrt(2) * deviation)));
// If precision is not enough to compute true deviation - use limit value deviation *= Math.Sqrt(1 - okHitWindowTailAmount);
if (pLowerBound == 0 || randomValue >= 1 || deviation > limitValue) }
deviation = limitValue; else
{
// A tested limit value for the case of a score only containing oks.
deviation = okHitWindow / Math.Sqrt(3);
}
// Then compute the variance for mehs. // Compute and add the variance for mehs, assuming that they are uniformly distributed.
double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3; double mehVariance = (mehHitWindow * mehHitWindow + okHitWindow * mehHitWindow + okHitWindow * okHitWindow) / 3;
// Find the total deviation.
deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh)); deviation = Math.Sqrt(((relevantCountGreat + relevantCountOk) * Math.Pow(deviation, 2) + relevantCountMeh * mehVariance) / (relevantCountGreat + relevantCountOk + relevantCountMeh));
return deviation; return deviation;

View File

@@ -0,0 +1,213 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Mods;
namespace osu.Game.Rulesets.Osu.Difficulty
{
public class OsuRatingCalculator
{
private const double difficulty_multiplier = 0.0675;
private readonly Mod[] mods;
private readonly int totalHits;
private readonly double approachRate;
private readonly double overallDifficulty;
private readonly double mechanicalDifficultyRating;
private readonly double sliderFactor;
public OsuRatingCalculator(Mod[] mods, int totalHits, double approachRate, double overallDifficulty, double mechanicalDifficultyRating, double sliderFactor)
{
this.mods = mods;
this.totalHits = totalHits;
this.approachRate = approachRate;
this.overallDifficulty = overallDifficulty;
this.mechanicalDifficultyRating = mechanicalDifficultyRating;
this.sliderFactor = sliderFactor;
}
public double ComputeAimRating(double aimDifficultyValue)
{
if (mods.Any(m => m is OsuModAutopilot))
return 0;
double aimRating = CalculateDifficultyRating(aimDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
aimRating = Math.Pow(aimRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
aimRating *= 0.9;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
aimRating *= 1.0 - magnetisedStrength;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
else if (approachRate < 8.0)
approachRateFactor = 0.05 * (8.0 - approachRate);
if (mods.Any(h => h is OsuModRelax))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateAimVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor, sliderFactor);
}
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return aimRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeSpeedRating(double speedDifficultyValue)
{
if (mods.Any(m => m is OsuModRelax))
return 0;
double speedRating = CalculateDifficultyRating(speedDifficultyValue);
if (mods.Any(m => m is OsuModAutopilot))
speedRating *= 0.5;
if (mods.Any(m => m is OsuModMagnetised))
{
// reduce speed rating because of the speed distance scaling, with maximum reduction being 0.7x
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
speedRating *= 1.0 - magnetisedStrength * 0.3;
}
double ratingMultiplier = 1.0;
double approachRateLengthBonus = 0.95 + 0.4 * Math.Min(1.0, totalHits / 2000.0) +
(totalHits > 2000 ? Math.Log10(totalHits / 2000.0) * 0.5 : 0.0);
double approachRateFactor = 0.0;
if (approachRate > 10.33)
approachRateFactor = 0.3 * (approachRate - 10.33);
if (mods.Any(m => m is OsuModAutopilot))
approachRateFactor = 0.0;
ratingMultiplier += approachRateFactor * approachRateLengthBonus; // Buff for longer maps with high AR.
if (mods.Any(m => m is OsuModHidden))
{
double visibilityFactor = calculateSpeedVisibilityFactor(approachRate);
ratingMultiplier += CalculateVisibilityBonus(mods, approachRate, visibilityFactor);
}
ratingMultiplier *= 0.95 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 750;
return speedRating * Math.Cbrt(ratingMultiplier);
}
public double ComputeFlashlightRating(double flashlightDifficultyValue)
{
if (!mods.Any(m => m is OsuModFlashlight))
return 0;
double flashlightRating = CalculateDifficultyRating(flashlightDifficultyValue);
if (mods.Any(m => m is OsuModTouchDevice))
flashlightRating = Math.Pow(flashlightRating, 0.8);
if (mods.Any(m => m is OsuModRelax))
flashlightRating *= 0.7;
else if (mods.Any(m => m is OsuModAutopilot))
flashlightRating *= 0.4;
if (mods.Any(m => m is OsuModMagnetised))
{
float magnetisedStrength = mods.OfType<OsuModMagnetised>().First().AttractionStrength.Value;
flashlightRating *= 1.0 - magnetisedStrength;
}
if (mods.Any(m => m is OsuModDeflate))
{
float deflateInitialScale = mods.OfType<OsuModDeflate>().First().StartScale.Value;
flashlightRating *= Math.Clamp(DifficultyCalculationUtils.ReverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
}
double ratingMultiplier = 1.0;
// Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
ratingMultiplier *= 0.7 + 0.1 * Math.Min(1.0, totalHits / 200.0) +
(totalHits > 200 ? 0.2 * Math.Min(1.0, (totalHits - 200) / 200.0) : 0.0);
// It is important to consider accuracy difficulty when scaling with accuracy.
ratingMultiplier *= 0.98 + Math.Pow(Math.Max(0, overallDifficulty), 2) / 2500;
return flashlightRating * Math.Sqrt(ratingMultiplier);
}
private double calculateAimVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(9, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
private double calculateSpeedVisibilityFactor(double approachRate)
{
const double ar_factor_end_point = 11.5;
double mechanicalDifficultyFactor = DifficultyCalculationUtils.ReverseLerp(mechanicalDifficultyRating, 5, 10);
double arFactorStartingPoint = double.Lerp(10, 10.33, mechanicalDifficultyFactor);
return DifficultyCalculationUtils.ReverseLerp(approachRate, ar_factor_end_point, arFactorStartingPoint);
}
/// <summary>
/// Calculates a visibility bonus that is applicable to Hidden and Traceable.
/// </summary>
public static double CalculateVisibilityBonus(Mod[] mods, double approachRate, double visibilityFactor = 1, double sliderFactor = 1)
{
// NOTE: TC's effect is only noticeable in performance calculations until lazer mods are accounted for server-side.
bool isAlwaysPartiallyVisible = mods.OfType<OsuModHidden>().Any(m => m.OnlyFadeApproachCircles.Value) || mods.OfType<OsuModTraceable>().Any();
// Start from normal curve, rewarding lower AR up to AR7
// TC forcefully requires a lower reading bonus for now as it's post-applied in PP which makes it multiplicative with the regular AR bonuses
// This means it has an advantage over HD, so we decrease the multiplier to compensate
// This should be removed once we're able to apply TC bonuses in SR (depends on real-time difficulty calculations being possible)
double readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) * (12.0 - Math.Max(approachRate, 7));
readingBonus *= visibilityFactor;
// We want to reward slideraim on low AR less
double sliderVisibilityFactor = Math.Pow(sliderFactor, 3);
// For AR up to 0 - reduce reward for very low ARs when object is visible
if (approachRate < 7)
readingBonus += (isAlwaysPartiallyVisible ? 0.02 : 0.045) * (7.0 - Math.Max(approachRate, 0)) * sliderVisibilityFactor;
// Starting from AR0 - cap values so they won't grow to infinity
if (approachRate < 0)
readingBonus += (isAlwaysPartiallyVisible ? 0.01 : 0.1) * (1 - Math.Pow(1.5, approachRate)) * sliderVisibilityFactor;
return readingBonus;
}
public static double CalculateDifficultyRating(double difficultyValue) => Math.Sqrt(difficultyValue) * difficulty_multiplier;
}
}

View File

@@ -28,11 +28,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f; private const float assumed_slider_radius = NORMALISED_RADIUS * 1.8f;
protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject; protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject;
protected new OsuHitObject LastObject => (OsuHitObject)base.LastObject;
/// <summary> /// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms. /// <see cref="DifficultyHitObject.DeltaTime"/> capped to a minimum of <see cref="MIN_DELTA_TIME"/>ms.
/// </summary> /// </summary>
public readonly double StrainTime; public readonly double AdjustedDeltaTime;
/// <summary> /// <summary>
/// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>. /// Normalised distance from the "lazy" end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
@@ -75,6 +76,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public double TravelTime { get; private set; } public double TravelTime { get; private set; }
/// <summary>
/// The position of the cursor at the point of completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public Vector2? LazyEndPosition { get; private set; }
/// <summary>
/// The distance travelled by the cursor upon completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public double LazyTravelDistance { get; private set; }
/// <summary>
/// The time taken by the cursor upon completion of this <see cref="OsuDifficultyHitObject"/> if it is a <see cref="Slider"/>
/// and was hit with as few movements as possible.
/// </summary>
public double LazyTravelTime { get; private set; }
/// <summary> /// <summary>
/// Angle the player has to take to hit this <see cref="OsuDifficultyHitObject"/>. /// Angle the player has to take to hit this <see cref="OsuDifficultyHitObject"/>.
/// Calculated as the angle between the circles (current-2, current-1, current). /// Calculated as the angle between the circles (current-2, current-1, current).
@@ -86,17 +105,24 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary> /// </summary>
public double HitWindowGreat { get; private set; } public double HitWindowGreat { get; private set; }
private readonly OsuHitObject? lastLastObject; /// <summary>
private readonly OsuHitObject lastObject; /// Selective bonus for maps with higher circle size.
/// </summary>
public double SmallCircleBonus { get; private set; }
public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, HitObject? lastLastObject, double clockRate, List<DifficultyHitObject> objects, int index) private readonly OsuDifficultyHitObject? lastLastDifficultyObject;
private readonly OsuDifficultyHitObject? lastDifficultyObject;
public OsuDifficultyHitObject(HitObject hitObject, HitObject lastObject, double clockRate, List<DifficultyHitObject> objects, int index)
: base(hitObject, lastObject, clockRate, objects, index) : base(hitObject, lastObject, clockRate, objects, index)
{ {
this.lastLastObject = lastLastObject as OsuHitObject; lastLastDifficultyObject = index > 1 ? (OsuDifficultyHitObject)objects[index - 2] : null;
this.lastObject = (OsuHitObject)lastObject; lastDifficultyObject = index > 0 ? (OsuDifficultyHitObject)objects[index - 1] : null;
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects. // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, MIN_DELTA_TIME); AdjustedDeltaTime = Math.Max(DeltaTime, MIN_DELTA_TIME);
SmallCircleBonus = Math.Max(1.0, 1.0 + (30 - BaseObject.Radius) / 40);
if (BaseObject is Slider sliderObject) if (BaseObject is Slider sliderObject)
{ {
@@ -107,6 +133,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate; HitWindowGreat = 2 * BaseObject.HitWindows.WindowFor(HitResult.Great) / clockRate;
} }
computeSliderCursorPosition();
setDistances(clockRate); setDistances(clockRate);
} }
@@ -161,35 +188,28 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{ {
if (BaseObject is Slider currentSlider) if (BaseObject is Slider currentSlider)
{ {
computeSliderCursorPosition(currentSlider);
// Bonus for repeat sliders until a better per nested object strain system can be achieved. // Bonus for repeat sliders until a better per nested object strain system can be achieved.
TravelDistance = currentSlider.LazyTravelDistance * (float)Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5); TravelDistance = LazyTravelDistance * Math.Pow(1 + currentSlider.RepeatCount / 2.5, 1.0 / 2.5);
TravelTime = Math.Max(currentSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); TravelTime = Math.Max(LazyTravelTime / clockRate, MIN_DELTA_TIME);
} }
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || lastObject is Spinner) if (BaseObject is Spinner || LastObject is Spinner)
return; return;
// We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps. // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius; float scalingFactor = NORMALISED_RADIUS / (float)BaseObject.Radius;
if (BaseObject.Radius < 30) Vector2 lastCursorPosition = lastDifficultyObject != null ? getEndCursorPosition(lastDifficultyObject) : LastObject.StackedPosition;
{
float smallCircleBonus = Math.Min(30 - (float)BaseObject.Radius, 5) / 50;
scalingFactor *= 1 + smallCircleBonus;
}
Vector2 lastCursorPosition = getEndCursorPosition(lastObject);
LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length; LazyJumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
MinimumJumpTime = StrainTime; MinimumJumpTime = AdjustedDeltaTime;
MinimumJumpDistance = LazyJumpDistance; MinimumJumpDistance = LazyJumpDistance;
if (lastObject is Slider lastSlider) if (LastObject is Slider lastSlider && lastDifficultyObject != null)
{ {
double lastTravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, MIN_DELTA_TIME); double lastTravelTime = Math.Max(lastDifficultyObject.LazyTravelTime / clockRate, MIN_DELTA_TIME);
MinimumJumpTime = Math.Max(StrainTime - lastTravelTime, MIN_DELTA_TIME); MinimumJumpTime = Math.Max(AdjustedDeltaTime - lastTravelTime, MIN_DELTA_TIME);
// //
// There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects. // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
@@ -217,11 +237,11 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius)); MinimumJumpDistance = Math.Max(0, Math.Min(LazyJumpDistance - (maximum_slider_radius - assumed_slider_radius), tailJumpDistance - maximum_slider_radius));
} }
if (lastLastObject != null && !(lastLastObject is Spinner)) if (lastLastDifficultyObject != null && lastLastDifficultyObject.BaseObject is not Spinner)
{ {
Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastObject); Vector2 lastLastCursorPosition = getEndCursorPosition(lastLastDifficultyObject);
Vector2 v1 = lastLastCursorPosition - lastObject.StackedPosition; Vector2 v1 = lastLastCursorPosition - LastObject.StackedPosition;
Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition; Vector2 v2 = BaseObject.StackedPosition - lastCursorPosition;
float dot = Vector2.Dot(v1, v2); float dot = Vector2.Dot(v1, v2);
@@ -231,9 +251,12 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
} }
} }
private void computeSliderCursorPosition(Slider slider) private void computeSliderCursorPosition()
{ {
if (slider.LazyEndPosition != null) if (BaseObject is not Slider slider)
return;
if (LazyEndPosition != null)
return; return;
// TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from // TODO: This commented version is actually correct by the new lazer implementation, but intentionally held back from
@@ -280,15 +303,15 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
nestedObjects = reordered; nestedObjects = reordered;
} }
slider.LazyTravelTime = trackingEndTime - slider.StartTime; LazyTravelTime = trackingEndTime - slider.StartTime;
double endTimeMin = slider.LazyTravelTime / slider.SpanDuration; double endTimeMin = LazyTravelTime / slider.SpanDuration;
if (endTimeMin % 2 >= 1) if (endTimeMin % 2 >= 1)
endTimeMin = 1 - endTimeMin % 1; endTimeMin = 1 - endTimeMin % 1;
else else
endTimeMin %= 1; endTimeMin %= 1;
slider.LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived. LazyEndPosition = slider.StackedPosition + slider.Path.PositionAt(endTimeMin); // temporary lazy end position until a real result can be derived.
Vector2 currCursorPosition = slider.StackedPosition; Vector2 currCursorPosition = slider.StackedPosition;
@@ -310,7 +333,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement. // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
// For sliders that are circular, the lazy end position may actually be farther away than the sliders true end. // For sliders that are circular, the lazy end position may actually be farther away than the sliders true end.
// This code is designed to prevent buffing situations where lazy end is actually a less efficient movement. // This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
Vector2 lazyMovement = Vector2.Subtract((Vector2)slider.LazyEndPosition, currCursorPosition); Vector2 lazyMovement = Vector2.Subtract((Vector2)LazyEndPosition, currCursorPosition);
if (lazyMovement.Length < currMovement.Length) if (lazyMovement.Length < currMovement.Length)
currMovement = lazyMovement; currMovement = lazyMovement;
@@ -328,25 +351,17 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
// this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance. // this finds the positional delta from the required radius and the current position, and updates the currCursorPosition accordingly, as well as rewarding distance.
currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength))); currCursorPosition = Vector2.Add(currCursorPosition, Vector2.Multiply(currMovement, (float)((currMovementLength - requiredMovement) / currMovementLength)));
currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength; currMovementLength *= (currMovementLength - requiredMovement) / currMovementLength;
slider.LazyTravelDistance += (float)currMovementLength; LazyTravelDistance += currMovementLength;
} }
if (i == nestedObjects.Count - 1) if (i == nestedObjects.Count - 1)
slider.LazyEndPosition = currCursorPosition; LazyEndPosition = currCursorPosition;
} }
} }
private Vector2 getEndCursorPosition(OsuHitObject hitObject) private Vector2 getEndCursorPosition(OsuDifficultyHitObject difficultyHitObject)
{ {
Vector2 pos = hitObject.StackedPosition; return difficultyHitObject.LazyEndPosition ?? difficultyHitObject.BaseObject.StackedPosition;
if (hitObject is Slider slider)
{
computeSliderCursorPosition(slider);
pos = slider.LazyEndPosition ?? pos;
}
return pos;
} }
} }
} }

View File

@@ -7,6 +7,7 @@ using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills namespace osu.Game.Rulesets.Osu.Difficulty.Skills
@@ -26,7 +27,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
private double currentStrain; private double currentStrain;
private double skillMultiplier => 25.6; private double skillMultiplier => 26;
private double strainDecayBase => 0.15; private double strainDecayBase => 0.15;
private readonly List<double> sliderStrains = new List<double>(); private readonly List<double> sliderStrains = new List<double>();
@@ -41,9 +42,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier; currentStrain += AimEvaluator.EvaluateDifficultyOf(current, IncludeSliders) * skillMultiplier;
if (current.BaseObject is Slider) if (current.BaseObject is Slider)
{
sliderStrains.Add(currentStrain); sliderStrains.Add(currentStrain);
}
return currentStrain; return currentStrain;
} }
@@ -54,10 +53,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return 0; return 0;
double maxSliderStrain = sliderStrains.Max(); double maxSliderStrain = sliderStrains.Max();
if (maxSliderStrain == 0) if (maxSliderStrain == 0)
return 0; return 0;
return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0)))); return sliderStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxSliderStrain * 12.0 - 6.0))));
} }
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
} }
} }

View File

@@ -2,11 +2,14 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Mods; using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu.Difficulty.Evaluators; using osu.Game.Rulesets.Osu.Difficulty.Evaluators;
using osu.Game.Rulesets.Osu.Difficulty.Preprocessing; using osu.Game.Rulesets.Osu.Difficulty.Preprocessing;
using osu.Game.Rulesets.Osu.Objects;
using System.Linq; using System.Linq;
using osu.Game.Rulesets.Osu.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Skills namespace osu.Game.Rulesets.Osu.Difficulty.Skills
{ {
@@ -15,12 +18,14 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary> /// </summary>
public class Speed : OsuStrainSkill public class Speed : OsuStrainSkill
{ {
private double skillMultiplier => 1.46; private double skillMultiplier => 1.47;
private double strainDecayBase => 0.3; private double strainDecayBase => 0.3;
private double currentStrain; private double currentStrain;
private double currentRhythm; private double currentRhythm;
private readonly List<double> sliderStrains = new List<double>();
protected override int ReducedSectionCount => 5; protected override int ReducedSectionCount => 5;
public Speed(Mod[] mods) public Speed(Mod[] mods)
@@ -34,13 +39,16 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current) protected override double StrainValueAt(DifficultyHitObject current)
{ {
currentStrain *= strainDecay(((OsuDifficultyHitObject)current).StrainTime); currentStrain *= strainDecay(((OsuDifficultyHitObject)current).AdjustedDeltaTime);
currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier; currentStrain += SpeedEvaluator.EvaluateDifficultyOf(current, Mods) * skillMultiplier;
currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current); currentRhythm = RhythmEvaluator.EvaluateDifficultyOf(current);
double totalStrain = currentStrain * currentRhythm; double totalStrain = currentStrain * currentRhythm;
if (current.BaseObject is Slider)
sliderStrains.Add(totalStrain);
return totalStrain; return totalStrain;
} }
@@ -55,5 +63,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0)))); return ObjectStrains.Sum(strain => 1.0 / (1.0 + Math.Exp(-(strain / maxStrain * 12.0 - 6.0))));
} }
public double CountTopWeightedSliders() => OsuStrainUtils.CountTopWeightedSliders(sliderStrains, DifficultyValue());
} }
} }

View File

@@ -0,0 +1,101 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Objects.Legacy;
using osu.Game.Rulesets.Osu.Objects;
namespace osu.Game.Rulesets.Osu.Difficulty.Utils
{
public static class LegacyScoreUtils
{
/// <summary>
/// Calculates the average amount of score per object that is caused by nested judgements such as slider-ticks and spinners.
/// </summary>
public static double CalculateNestedScorePerObject(IBeatmap beatmap, int objectCount)
{
const double big_tick_score = 30;
const double small_tick_score = 10;
var sliders = beatmap.HitObjects.OfType<Slider>().ToArray();
// 1 for head, 1 for tail
int amountOfBigTicks = sliders.Length * 2;
// Add slider repeats
amountOfBigTicks += sliders.Select(s => s.RepeatCount).Sum();
int amountOfSmallTicks = sliders.Select(s => s.NestedHitObjects.Count(nho => nho is SliderTick)).Sum();
double sliderScore = amountOfBigTicks * big_tick_score + amountOfSmallTicks * small_tick_score;
double spinnerScore = 0;
foreach (var spinner in beatmap.HitObjects.OfType<Spinner>())
{
spinnerScore += calculateSpinnerScore(spinner);
}
return (sliderScore + spinnerScore) / objectCount;
}
/// <remarks>
/// Logic borrowed from <see cref="OsuLegacyScoreSimulator.simulateHit"/> for basic score calculations.
/// </remarks>
private static double calculateSpinnerScore(Spinner spinner)
{
const int spin_score = 100;
const int bonus_spin_score = 1000;
// The spinner object applies a lenience because gameplay mechanics differ from osu-stable.
// We'll redo the calculations to match osu-stable here...
const double maximum_rotations_per_second = 477.0 / 60;
// Normally, this value depends on the final overall difficulty. For simplicity, we'll only consider the worst case that maximises bonus score.
// As we're primarily concerned with computing the maximum theoretical final score,
// this will have the final effect of slightly underestimating bonus score achieved on stable when converting from score V1.
const double minimum_rotations_per_second = 3;
double secondsDuration = spinner.Duration / 1000;
// The total amount of half spins possible for the entire spinner.
int totalHalfSpinsPossible = (int)(secondsDuration * maximum_rotations_per_second * 2);
// The amount of half spins that are required to successfully complete the spinner (i.e. get a 300).
int halfSpinsRequiredForCompletion = (int)(secondsDuration * minimum_rotations_per_second);
// To be able to receive bonus points, the spinner must be rotated another 1.5 times.
int halfSpinsRequiredBeforeBonus = halfSpinsRequiredForCompletion + 3;
long score = 0;
int fullSpins = (totalHalfSpinsPossible / 2);
// Normal spin score
score += spin_score * fullSpins;
int bonusSpins = (totalHalfSpinsPossible - halfSpinsRequiredBeforeBonus) / 2;
// Reduce amount of bonus spins because we want to represent the more average case, rather than the best one.
bonusSpins = Math.Max(0, bonusSpins - fullSpins / 2);
score += bonus_spin_score * bonusSpins;
return score;
}
public static int CalculateDifficultyPeppyStars(IBeatmap beatmap)
{
int objectCount = beatmap.HitObjects.Count;
int drainLength = 0;
if (objectCount > 0)
{
int breakLength = beatmap.Breaks.Select(b => (int)Math.Round(b.EndTime) - (int)Math.Round(b.StartTime)).Sum();
drainLength = ((int)Math.Round(beatmap.HitObjects[^1].StartTime) - (int)Math.Round(beatmap.HitObjects[0].StartTime) - breakLength) / 1000;
}
return LegacyRulesetExtensions.CalculateDifficultyPeppyStars(beatmap.Difficulty, objectCount, drainLength);
}
}
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Utils;
namespace osu.Game.Rulesets.Osu.Difficulty.Utils
{
public static class OsuStrainUtils
{
public static double CountTopWeightedSliders(IReadOnlyCollection<double> sliderStrains, double difficultyValue)
{
if (sliderStrains.Count == 0)
return 0;
double consistentTopStrain = difficultyValue / 10; // What would the top strain be if all strain values were identical
if (consistentTopStrain == 0)
return 0;
// Use a weighted sum of all strains. Constants are arbitrary and give nice values
return sliderStrains.Sum(s => DifficultyCalculationUtils.Logistic(s / consistentTopStrain, 0.88, 10, 1.1));
}
}
}

View File

@@ -469,9 +469,20 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders.Components
} }
else else
{ {
SnapResult result = positionSnapProvider?.TrySnapToPositionGrid(Parent!.ToScreenSpace(e.MousePosition)); Vector2 newControlPointPosition = Parent!.ToScreenSpace(e.MousePosition);
Vector2 movementDelta = Parent!.ToLocalSpace(result?.ScreenSpacePosition ?? Parent!.ToScreenSpace(e.MousePosition)) - dragStartPositions[draggedControlPointIndex] - hitObject.Position; // Snapping inherited B-spline control points to nearby objects would be unintuitive, because snapping them does not equate to snapping the interpolated slider path.
bool shouldSnapToNearbyObjects = dragPathTypes[draggedControlPointIndex] is not null ||
dragPathTypes[..draggedControlPointIndex].LastOrDefault(t => t is not null)?.Type != SplineType.BSpline;
SnapResult result = null;
if (shouldSnapToNearbyObjects)
result = positionSnapProvider?.TrySnapToNearbyObjects(newControlPointPosition, oldStartTime);
if (positionSnapProvider?.TrySnapToPositionGrid(result?.ScreenSpacePosition ?? newControlPointPosition, result?.Time ?? oldStartTime) is SnapResult gridSnapResult)
result = gridSnapResult;
result ??= new SnapResult(newControlPointPosition, oldStartTime);
Vector2 movementDelta = Parent!.ToLocalSpace(result.ScreenSpacePosition) - dragStartPositions[draggedControlPointIndex] - hitObject.Position;
for (int i = 0; i < controlPoints.Count; ++i) for (int i = 0; i < controlPoints.Count; ++i)
{ {

View File

@@ -626,10 +626,38 @@ namespace osu.Game.Rulesets.Osu.Edit.Blueprints.Sliders
public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset) public override Vector2 ScreenSpaceSelectionPoint => DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathOffset)
?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation); ?? BodyPiece.ToScreenSpace(BodyPiece.PathStartLocation);
protected override Vector2[] ScreenSpaceAdditionalNodes => new[] protected override Vector2[] ScreenSpaceAdditionalNodes => getScreenSpaceControlPointNodes().Prepend(
{
DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation) DrawableObject.SliderBody?.ToScreenSpace(DrawableObject.SliderBody.PathEndOffset) ?? BodyPiece.ToScreenSpace(BodyPiece.PathEndLocation)
}; ).ToArray();
private IEnumerable<Vector2> getScreenSpaceControlPointNodes()
{
// Returns the positions of control points that produce visible kinks on the slider's path
// This excludes inherited control points from Bezier, B-Spline, Perfect, and Catmull curves
if (DrawableObject.SliderBody == null)
yield break;
PathType? currentPathType = null;
// Skip the last control point because its always either not on the slider path or exactly on the slider end
for (int i = 0; i < DrawableObject.HitObject.Path.ControlPoints.Count - 1; i++)
{
var controlPoint = DrawableObject.HitObject.Path.ControlPoints[i];
if (controlPoint.Type is not null)
currentPathType = controlPoint.Type;
// Skip the first control point because it is already covered by the slider head
if (i == 0)
continue;
if (controlPoint.Type is null && currentPathType != PathType.LINEAR)
continue;
var screenSpacePosition = DrawableObject.SliderBody.ToScreenSpace(DrawableObject.SliderBody.PathOffset + controlPoint.Position);
yield return screenSpacePosition;
}
}
public override bool ReceivePositionalInputAt(Vector2 screenSpacePos) public override bool ReceivePositionalInputAt(Vector2 screenSpacePos)
{ {

View File

@@ -253,7 +253,7 @@ namespace osu.Game.Rulesets.Osu.Edit
{ {
var hitObjects = selectedMovableObjects; var hitObjects = selectedMovableObjects;
Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects); Quad quad = GeometryUtils.GetSurroundingQuad(hitObjects, true);
Vector2 delta = Vector2.Zero; Vector2 delta = Vector2.Zero;

View File

@@ -81,12 +81,8 @@ namespace osu.Game.Rulesets.Osu.Edit
changeHandler?.BeginChange(); changeHandler?.BeginChange();
objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho)); objectsInScale = selectedMovableObjects.ToDictionary(ho => ho, ho => new OriginalHitObjectState(ho));
OriginalSurroundingQuad = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider OriginalSurroundingQuad = GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
? GeometryUtils.GetSurroundingQuad(slider.Path.ControlPoints.Select(p => slider.Position + p.Position)) originalConvexHull = GeometryUtils.GetConvexHull(objectsInScale.Keys);
: GeometryUtils.GetSurroundingQuad(objectsInScale.Keys);
originalConvexHull = objectsInScale.Count == 1 && objectsInScale.First().Key is Slider slider2
? GeometryUtils.GetConvexHull(slider2.Path.ControlPoints.Select(p => slider2.Position + p.Position))
: GeometryUtils.GetConvexHull(objectsInScale.Keys);
defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1; defaultOrigin = GeometryUtils.MinimumEnclosingCircle(originalConvexHull).Item1;
} }
@@ -312,7 +308,7 @@ namespace osu.Game.Rulesets.Osu.Edit
private void moveSelectionInBounds() private void moveSelectionInBounds()
{ {
Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys); Quad quad = GeometryUtils.GetSurroundingQuad(objectsInScale!.Keys, true);
Vector2 delta = Vector2.Zero; Vector2 delta = Vector2.Zero;

View File

@@ -14,7 +14,13 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray(); public override Type[] IncompatibleMods => base.IncompatibleMods.Concat(new[] { typeof(OsuModMagnetised), typeof(OsuModRepel), typeof(OsuModAutopilot), typeof(OsuModSpunOut), typeof(OsuModAlternate), typeof(OsuModSingleTap) }).ToArray();
#if !DEBUG
private string username = "Autoplay";
#else
private string username = "Chicony";
#endif
public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods) public override ModReplayData CreateReplayData(IBeatmap beatmap, IReadOnlyList<Mod> mods)
=> new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = "Autoplay" }); => new ModReplayData(new OsuAutoGenerator(beatmap, mods).Generate(), new ModCreatedUser { Username = username });
} }
} }

View File

@@ -16,13 +16,19 @@ namespace osu.Game.Rulesets.Osu.Mods
{ {
public partial class OsuModDifficultyAdjust : ModDifficultyAdjust public partial class OsuModDifficultyAdjust : ModDifficultyAdjust
{ {
[SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))] [SettingSource("Circle Size", "Override a beatmap's set CS.", FIRST_SETTING_ORDER - 1, SettingControlType = typeof(DifficultyAdjustSettingsControl))]
public DifficultyBindable CircleSize { get; } = new DifficultyBindable public DifficultyBindable CircleSize { get; } = new DifficultyBindable
{ {
Precision = 0.1f, Precision = 0.1f,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
#if !DEBUG
ExtendedMaxValue = 11, ExtendedMaxValue = 11,
#else
ExtendedMinValue = -250,
ExtendedMaxValue = 13,
#endif
ReadCurrentFromDifficulty = diff => diff.CircleSize, ReadCurrentFromDifficulty = diff => diff.CircleSize,
}; };
@@ -32,8 +38,13 @@ namespace osu.Game.Rulesets.Osu.Mods
Precision = 0.1f, Precision = 0.1f,
MinValue = 0, MinValue = 0,
MaxValue = 10, MaxValue = 10,
#if !DEBUG
ExtendedMinValue = -10, ExtendedMinValue = -10,
ExtendedMaxValue = 11, ExtendedMaxValue = 11,
#else
ExtendedMinValue = -250,
ExtendedMaxValue = 13,
#endif
ReadCurrentFromDifficulty = diff => diff.ApproachRate, ReadCurrentFromDifficulty = diff => diff.ApproachRate,
}; };
@@ -51,7 +62,8 @@ namespace osu.Game.Rulesets.Osu.Mods
return string.Empty; return string.Empty;
string format(string acronym, DifficultyBindable bindable) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}"; string format(string acronym, DifficultyBindable bindable)
=> $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(1)}";
} }
} }

View File

@@ -68,24 +68,6 @@ namespace osu.Game.Rulesets.Osu.Objects
} }
} }
/// <summary>
/// The position of the cursor at the point of completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal Vector2? LazyEndPosition;
/// <summary>
/// The distance travelled by the cursor upon completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal float LazyTravelDistance;
/// <summary>
/// The time taken by the cursor upon completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal double LazyTravelTime;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>(); public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
[JsonIgnore] [JsonIgnore]

View File

@@ -62,24 +62,6 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
RelativeSizeAxes = Axes.Both, RelativeSizeAxes = Axes.Both,
Children = new Drawable[] Children = new Drawable[]
{ {
spin = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-spin"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 335,
},
clear = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-clear"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 115,
},
bonusCounter = new LegacySpriteText(LegacyFont.Score) bonusCounter = new LegacySpriteText(LegacyFont.Score)
{ {
Alpha = 0, Alpha = 0,
@@ -103,6 +85,24 @@ namespace osu.Game.Rulesets.Osu.Skinning.Legacy
Scale = new Vector2(SPRITE_SCALE * 0.9f), Scale = new Vector2(SPRITE_SCALE * 0.9f),
Position = new Vector2(80, 448 + spm_hide_offset), Position = new Vector2(80, 448 + spm_hide_offset),
}, },
spin = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-spin"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 335,
},
clear = new Sprite
{
Alpha = 0,
Anchor = Anchor.TopCentre,
Origin = Anchor.Centre,
Texture = source.GetTexture("spinner-clear"),
Scale = new Vector2(SPRITE_SCALE),
Y = SPINNER_TOP_OFFSET + 115,
},
} }
}); });
} }

View File

@@ -4,6 +4,7 @@
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Bindables; using osu.Framework.Bindables;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Rulesets.Osu.Configuration; using osu.Game.Rulesets.Osu.Configuration;
using osu.Game.Screens.Play.PlayerSettings; using osu.Game.Screens.Play.PlayerSettings;
@@ -13,19 +14,19 @@ namespace osu.Game.Rulesets.Osu.UI
{ {
private readonly OsuRulesetConfigManager config; private readonly OsuRulesetConfigManager config;
[SettingSource("Show click markers", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowClickMarkers), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowClickMarkers { get; } = new BindableBool(); public BindableBool ShowClickMarkers { get; } = new BindableBool();
[SettingSource("Show frame markers", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowFrameMarkers), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowAimMarkers { get; } = new BindableBool(); public BindableBool ShowAimMarkers { get; } = new BindableBool();
[SettingSource("Show cursor path", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.ShowCursorPath), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool ShowCursorPath { get; } = new BindableBool(); public BindableBool ShowCursorPath { get; } = new BindableBool();
[SettingSource("Hide gameplay cursor", SettingControlType = typeof(PlayerCheckbox))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.HideGameplayCursor), SettingControlType = typeof(PlayerCheckbox))]
public BindableBool HideSkinCursor { get; } = new BindableBool(); public BindableBool HideSkinCursor { get; } = new BindableBool();
[SettingSource("Display length", SettingControlType = typeof(PlayerSliderBar<int>))] [SettingSource(typeof(PlayerSettingsOverlayStrings), nameof(PlayerSettingsOverlayStrings.DisplayLength), SettingControlType = typeof(PlayerSliderBar<int>))]
public BindableInt DisplayLength { get; } = new BindableInt public BindableInt DisplayLength { get; } = new BindableInt
{ {
MinValue = 200, MinValue = 200,
@@ -35,7 +36,7 @@ namespace osu.Game.Rulesets.Osu.UI
}; };
public ReplayAnalysisSettings(OsuRulesetConfigManager config) public ReplayAnalysisSettings(OsuRulesetConfigManager config)
: base("Analysis Settings") : base(PlayerSettingsOverlayStrings.AnalysisSettingsTitle)
{ {
this.config = config; this.config = config;
} }

View File

@@ -14,13 +14,13 @@ namespace osu.Game.Rulesets.Taiko.Tests
{ {
protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko"; protected override string ResourceAssembly => "osu.Game.Rulesets.Taiko";
[TestCase(3.305554470092722d, 200, "diffcalc-test")] [TestCase(3.3190848563395079d, 200, "diffcalc-test")]
[TestCase(3.305554470092722d, 200, "diffcalc-test-strong")] [TestCase(3.3190848563395079d, 200, "diffcalc-test-strong")]
public void Test(double expectedStarRating, int expectedMaxCombo, string name) public void Test(double expectedStarRating, int expectedMaxCombo, string name)
=> base.Test(expectedStarRating, expectedMaxCombo, name); => base.Test(expectedStarRating, expectedMaxCombo, name);
[TestCase(4.4472572672057815d, 200, "diffcalc-test")] [TestCase(4.4551414906554987d, 200, "diffcalc-test")]
[TestCase(4.4472572672057815d, 200, "diffcalc-test-strong")] [TestCase(4.4551414906554987d, 200, "diffcalc-test-strong")]
public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name) public void TestClockRateAdjusted(double expectedStarRating, int expectedMaxCombo, string name)
=> Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime()); => Test(expectedStarRating, expectedMaxCombo, name, new TaikoModDoubleTime());

View File

@@ -2,6 +2,8 @@
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System; using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing; using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
@@ -24,7 +26,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
int consistentRatioCount = 0; int consistentRatioCount = 0;
double totalRatioCount = 0.0; double totalRatioCount = 0.0;
List<double> recentRatios = new List<double>();
TaikoDifficultyHitObject current = hitObject; TaikoDifficultyHitObject current = hitObject;
var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1);
for (int i = 0; i < maxObjectsToCheck; i++) for (int i = 0; i < maxObjectsToCheck; i++)
{ {
@@ -32,11 +36,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
if (current.Index <= 1) if (current.Index <= 1)
break; break;
var previousHitObject = (TaikoDifficultyHitObject)current.Previous(1);
double currentRatio = current.RhythmData.Ratio; double currentRatio = current.RhythmData.Ratio;
double previousRatio = previousHitObject.RhythmData.Ratio; double previousRatio = previousHitObject.RhythmData.Ratio;
recentRatios.Add(currentRatio);
// A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error. // A consistent interval is defined as the percentage difference between the two rhythmic ratios with the margin of error.
if (Math.Abs(1 - currentRatio / previousRatio) <= threshold) if (Math.Abs(1 - currentRatio / previousRatio) <= threshold)
{ {
@@ -45,14 +49,21 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Evaluators
break; break;
} }
// Move to the previous object
current = previousHitObject; current = previousHitObject;
} }
// Ensure no division by zero // Ensure no division by zero
double ratioPenalty = 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80; if (consistentRatioCount > 0)
return 1 - totalRatioCount / (consistentRatioCount + 1) * 0.80;
return ratioPenalty; if (recentRatios.Count <= 1) return 1.0;
// As a fallback, calculate the maximum deviation from the average of the recent ratios to ensure slightly off-snapped objects don't bypass the penalty.
double maxRatioDeviation = recentRatios.Max(r => Math.Abs(r - recentRatios.Average()));
double consistentRatioPenalty = 0.7 + 0.3 * DifficultyCalculationUtils.Smootherstep(maxRatioDeviation, 0.0, 1.0);
return consistentRatioPenalty;
} }
/// <summary> /// <summary>

View File

@@ -1,7 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Difficulty.Preprocessing; using osu.Game.Rulesets.Difficulty.Preprocessing;
using osu.Game.Rulesets.Taiko.Difficulty.Utils; using osu.Game.Rulesets.Taiko.Difficulty.Utils;
@@ -18,6 +20,8 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
public readonly SameRhythmHitObjectGrouping? Previous; public readonly SameRhythmHitObjectGrouping? Previous;
private const double snap_tolerance = IntervalGroupingUtils.MARGIN_OF_ERROR;
/// <summary> /// <summary>
/// <see cref="DifficultyHitObject.StartTime"/> of the first hit object. /// <see cref="DifficultyHitObject.StartTime"/> of the first hit object.
/// </summary> /// </summary>
@@ -29,35 +33,60 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Preprocessing.Rhythm.Data
public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime; public double Duration => HitObjects[^1].StartTime - HitObjects[0].StartTime;
/// <summary> /// <summary>
/// The interval in ms of each hit object in this <see cref="SameRhythmHitObjectGrouping"/>. This is only defined if there is /// The normalised interval in ms of each hit object in this <see cref="SameRhythmHitObjectGrouping"/>. This is only defined if there is
/// more than two hit objects in this <see cref="SameRhythmHitObjectGrouping"/>. /// more than two hit objects in this <see cref="SameRhythmHitObjectGrouping"/>.
/// </summary> /// </summary>
public readonly double? HitObjectInterval; public readonly double? HitObjectInterval;
/// <summary> /// <summary>
/// The ratio of <see cref="HitObjectInterval"/> between this and the previous <see cref="SameRhythmHitObjectGrouping"/>. In the /// The normalised ratio of <see cref="HitObjectInterval"/> between this and the previous <see cref="SameRhythmHitObjectGrouping"/>. In the
/// case where one or both of the <see cref="HitObjectInterval"/> is undefined, this will have a value of 1. /// case where one or both of the <see cref="HitObjectInterval"/> is undefined, this will have a value of 1.
/// </summary> /// </summary>
public readonly double HitObjectIntervalRatio; public readonly double HitObjectIntervalRatio;
/// <inheritdoc/> /// <inheritdoc/>
public double Interval { get; } public double Interval { get; } = double.PositiveInfinity;
public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List<TaikoDifficultyHitObject> hitObjects) public SameRhythmHitObjectGrouping(SameRhythmHitObjectGrouping? previous, List<TaikoDifficultyHitObject> hitObjects)
{ {
Previous = previous; Previous = previous;
HitObjects = hitObjects; HitObjects = hitObjects;
// Calculate the average interval between hitobjects, or null if there are fewer than two // Cluster and normalise each hitobjects delta-time.
HitObjectInterval = HitObjects.Count < 2 ? null : Duration / (HitObjects.Count - 1); var normaliseHitObjects = DeltaTimeNormaliser.Normalise(hitObjects, snap_tolerance);
var normalisedHitObjectDeltaTime = hitObjects
.Skip(1)
.Select(hitObject => normaliseHitObjects[hitObject])
.ToList();
// Secondary check to ensure there isn't any 'noise' or outliers by taking the modal delta time.
double modalDelta = normalisedHitObjectDeltaTime.Count > 0
? Math.Round(normalisedHitObjectDeltaTime[0])
: 0;
// Calculate the average interval between hitobjects.
if (normalisedHitObjectDeltaTime.Count > 0)
{
if (previous?.HitObjectInterval is double previousDelta && Math.Abs(modalDelta - previousDelta) <= snap_tolerance)
HitObjectInterval = previousDelta;
else
HitObjectInterval = modalDelta;
}
// Calculate the ratio between this group's interval and the previous group's interval // Calculate the ratio between this group's interval and the previous group's interval
HitObjectIntervalRatio = Previous?.HitObjectInterval != null && HitObjectInterval != null HitObjectIntervalRatio = previous?.HitObjectInterval is double previousInterval && HitObjectInterval is double currentInterval
? HitObjectInterval.Value / Previous.HitObjectInterval.Value ? currentInterval / previousInterval
: 1; : 1.0;
// Calculate the interval from the previous group's start time // Calculate the interval from the previous group's start time
Interval = Previous != null ? StartTime - Previous.StartTime : double.PositiveInfinity; if (previous != null)
{
if (Math.Abs(StartTime - previous.StartTime) <= snap_tolerance)
Interval = 0;
else
Interval = StartTime - previous.StartTime;
}
} }
} }
} }

View File

@@ -42,20 +42,28 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Skills
protected override double StrainValueAt(DifficultyHitObject current) protected override double StrainValueAt(DifficultyHitObject current)
{ {
currentStrain *= strainDecay(current.DeltaTime); currentStrain *= strainDecay(current.DeltaTime);
currentStrain += StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier; double staminaDifficulty = StaminaEvaluator.EvaluateDifficultyOf(current) * skillMultiplier;
// Safely prevents previous strains from shifting as new notes are added. // Safely prevents previous strains from shifting as new notes are added.
var currentObject = current as TaikoDifficultyHitObject; var currentObject = current as TaikoDifficultyHitObject;
int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0; int index = currentObject?.ColourData.MonoStreak?.HitObjects.IndexOf(currentObject) ?? 0;
double monolengthBonus = isConvert ? 1 : 1 + Math.Min(Math.Max((index - 5) / 50.0, 0), 0.30); double monoLengthBonus = isConvert ? 1.0 : 1.0 + 0.5 * DifficultyCalculationUtils.ReverseLerp(index, 5, 20);
if (SingleColourStamina) // Mono-streak bonus is only applied to colour-based stamina to reward longer sequences of same-colour hits within patterns.
return DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain); if (!SingleColourStamina)
staminaDifficulty *= monoLengthBonus;
return currentStrain * monolengthBonus; currentStrain += staminaDifficulty;
// For converted maps, difficulty often comes entirely from long mono streams with no colour variation.
// To avoid over-rewarding these maps based purely on stamina strain, we dampen the strain value once the index exceeds 10.
return SingleColourStamina ? DifficultyCalculationUtils.Logistic(-(index - 10) / 2.0, currentStrain) : currentStrain;
} }
protected override double CalculateInitialStrain(double time, DifficultyHitObject current) => SingleColourStamina ? 0 : currentStrain * strainDecay(time - current.Previous(0).StartTime); protected override double CalculateInitialStrain(double time, DifficultyHitObject current) =>
SingleColourStamina
? 0
: currentStrain * strainDecay(time - current.Previous(0).StartTime);
} }
} }

View File

@@ -10,9 +10,16 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
public class TaikoDifficultyAttributes : DifficultyAttributes public class TaikoDifficultyAttributes : DifficultyAttributes
{ {
/// <summary>
/// The difficulty corresponding to the mechanical skills in osu!taiko.
/// This includes colour and stamina combined.
/// </summary>
public double MechanicalDifficulty { get; set; }
/// <summary> /// <summary>
/// The difficulty corresponding to the rhythm skill. /// The difficulty corresponding to the rhythm skill.
/// </summary> /// </summary>
[JsonProperty("rhythm_difficulty")]
public double RhythmDifficulty { get; set; } public double RhythmDifficulty { get; set; }
/// <summary> /// <summary>
@@ -36,9 +43,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("mono_stamina_factor")] [JsonProperty("mono_stamina_factor")]
public double MonoStaminaFactor { get; set; } public double MonoStaminaFactor { get; set; }
public double RhythmTopStrains { get; set; } /// <summary>
/// The factor corresponding to the consistency of a map.
public double ColourTopStrains { get; set; } /// </summary>
[JsonProperty("consistency_factor")]
public double ConsistencyFactor { get; set; }
public double StaminaTopStrains { get; set; } public double StaminaTopStrains { get; set; }
@@ -48,7 +57,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
yield return v; yield return v;
yield return (ATTRIB_ID_DIFFICULTY, StarRating); yield return (ATTRIB_ID_DIFFICULTY, StarRating);
yield return (ATTRIB_ID_RHYTHM_DIFFICULTY, RhythmDifficulty);
yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor); yield return (ATTRIB_ID_MONO_STAMINA_FACTOR, MonoStaminaFactor);
yield return (ATTRIB_ID_CONSISTENCY_FACTOR, ConsistencyFactor);
} }
public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo) public override void FromDatabaseAttributes(IReadOnlyDictionary<int, double> values, IBeatmapOnlineInfo onlineInfo)
@@ -56,7 +67,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
base.FromDatabaseAttributes(values, onlineInfo); base.FromDatabaseAttributes(values, onlineInfo);
StarRating = values[ATTRIB_ID_DIFFICULTY]; StarRating = values[ATTRIB_ID_DIFFICULTY];
RhythmDifficulty = values[ATTRIB_ID_RHYTHM_DIFFICULTY];
MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR]; MonoStaminaFactor = values[ATTRIB_ID_MONO_STAMINA_FACTOR];
ConsistencyFactor = values[ATTRIB_ID_CONSISTENCY_FACTOR];
} }
} }
} }

View File

@@ -23,7 +23,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
public class TaikoDifficultyCalculator : DifficultyCalculator public class TaikoDifficultyCalculator : DifficultyCalculator
{ {
private const double difficulty_multiplier = 0.084375; private const double difficulty_multiplier = 0.084375;
private const double rhythm_skill_multiplier = 0.65 * difficulty_multiplier; private const double rhythm_skill_multiplier = 0.750 * difficulty_multiplier;
private const double reading_skill_multiplier = 0.100 * difficulty_multiplier; private const double reading_skill_multiplier = 0.100 * difficulty_multiplier;
private const double colour_skill_multiplier = 0.375 * difficulty_multiplier; private const double colour_skill_multiplier = 0.375 * difficulty_multiplier;
private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier; private const double stamina_skill_multiplier = 0.445 * difficulty_multiplier;
@@ -31,9 +31,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double strainLengthBonus; private double strainLengthBonus;
private double patternMultiplier; private double patternMultiplier;
private bool isRelax;
private bool isConvert; private bool isConvert;
public override int Version => 20250306; public override int Version => 20251020;
public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap) public TaikoDifficultyCalculator(IRulesetInfo ruleset, IWorkingBeatmap beatmap)
: base(ruleset, beatmap) : base(ruleset, beatmap)
@@ -46,6 +47,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty); hitWindows.SetDifficulty(beatmap.Difficulty.OverallDifficulty);
isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0; isConvert = beatmap.BeatmapInfo.Ruleset.OnlineID == 0;
isRelax = mods.Any(h => h is TaikoModRelax);
return new Skill[] return new Skill[]
{ {
@@ -100,47 +102,50 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (beatmap.HitObjects.Count == 0) if (beatmap.HitObjects.Count == 0)
return new TaikoDifficultyAttributes { Mods = mods }; return new TaikoDifficultyAttributes { Mods = mods };
bool isRelax = mods.Any(h => h is TaikoModRelax);
var rhythm = skills.OfType<Rhythm>().Single(); var rhythm = skills.OfType<Rhythm>().Single();
var reading = skills.OfType<Reading>().Single(); var reading = skills.OfType<Reading>().Single();
var colour = skills.OfType<Colour>().Single(); var colour = skills.OfType<Colour>().Single();
var stamina = skills.OfType<Stamina>().Single(s => !s.SingleColourStamina); var stamina = skills.OfType<Stamina>().Single(s => !s.SingleColourStamina);
var singleColourStamina = skills.OfType<Stamina>().Single(s => s.SingleColourStamina); var singleColourStamina = skills.OfType<Stamina>().Single(s => s.SingleColourStamina);
double rhythmRating = rhythm.DifficultyValue() * rhythm_skill_multiplier; double rhythmSkill = rhythm.DifficultyValue() * rhythm_skill_multiplier;
double readingRating = reading.DifficultyValue() * reading_skill_multiplier; double readingSkill = reading.DifficultyValue() * reading_skill_multiplier;
double colourRating = colour.DifficultyValue() * colour_skill_multiplier; double colourSkill = colour.DifficultyValue() * colour_skill_multiplier;
double staminaRating = stamina.DifficultyValue() * stamina_skill_multiplier; double staminaSkill = stamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaRating = singleColourStamina.DifficultyValue() * stamina_skill_multiplier; double monoStaminaSkill = singleColourStamina.DifficultyValue() * stamina_skill_multiplier;
double monoStaminaFactor = staminaRating == 0 ? 1 : Math.Pow(monoStaminaRating / staminaRating, 5); double monoStaminaFactor = staminaSkill == 0 ? 1 : Math.Pow(monoStaminaSkill / staminaSkill, 5);
double colourDifficultStrains = colour.CountTopWeightedStrains();
double rhythmDifficultStrains = rhythm.CountTopWeightedStrains();
double staminaDifficultStrains = stamina.CountTopWeightedStrains(); double staminaDifficultStrains = stamina.CountTopWeightedStrains();
// As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm. // As we don't have pattern integration in osu!taiko, we apply the other two skills relative to rhythm.
patternMultiplier = Math.Pow(staminaRating * colourRating, 0.10); patternMultiplier = Math.Pow(staminaSkill * colourSkill, 0.10);
strainLengthBonus = 1 strainLengthBonus = 1 + 0.15 * DifficultyCalculationUtils.ReverseLerp(staminaDifficultStrains, 1000, 1555);
+ Math.Min(Math.Max((staminaDifficultStrains - 1000) / 3700, 0), 0.15)
+ Math.Min(Math.Max((staminaRating - 7.0) / 1.0, 0), 0.05);
double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, isRelax, isConvert); double combinedRating = combinedDifficultyValue(rhythm, reading, colour, stamina, out double consistencyFactor);
double starRating = rescale(combinedRating * 1.4); double starRating = rescale(combinedRating * 1.4);
// Calculate proportional contribution of each skill to the combinedRating.
double skillRating = starRating / (rhythmSkill + readingSkill + colourSkill + staminaSkill);
double rhythmDifficulty = rhythmSkill * skillRating;
double readingDifficulty = readingSkill * skillRating;
double colourDifficulty = colourSkill * skillRating;
double staminaDifficulty = staminaSkill * skillRating;
double mechanicalDifficulty = colourDifficulty + staminaDifficulty; // Mechanical difficulty is the sum of colour and stamina difficulties.
TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes TaikoDifficultyAttributes attributes = new TaikoDifficultyAttributes
{ {
StarRating = starRating, StarRating = starRating,
Mods = mods, Mods = mods,
RhythmDifficulty = rhythmRating, MechanicalDifficulty = mechanicalDifficulty,
ReadingDifficulty = readingRating, RhythmDifficulty = rhythmDifficulty,
ColourDifficulty = colourRating, ReadingDifficulty = readingDifficulty,
StaminaDifficulty = staminaRating, ColourDifficulty = colourDifficulty,
StaminaDifficulty = staminaDifficulty,
MonoStaminaFactor = monoStaminaFactor, MonoStaminaFactor = monoStaminaFactor,
RhythmTopStrains = rhythmDifficultStrains,
ColourTopStrains = colourDifficultStrains,
StaminaTopStrains = staminaDifficultStrains, StaminaTopStrains = staminaDifficultStrains,
ConsistencyFactor = consistencyFactor,
MaxCombo = beatmap.GetMaxCombo(), MaxCombo = beatmap.GetMaxCombo(),
}; };
@@ -154,14 +159,59 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// For each section, the peak strains of all separate skills are combined into a single peak strain for the section. /// For each section, the peak strains of all separate skills are combined into a single peak strain for the section.
/// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more). /// The resulting partial rating of the beatmap is a weighted sum of the combined peaks (higher peaks are weighted more).
/// </remarks> /// </remarks>
private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, bool isRelax, bool isConvert) private double combinedDifficultyValue(Rhythm rhythm, Reading reading, Colour colour, Stamina stamina, out double consistencyFactor)
{ {
List<double> peaks = new List<double>(); List<double> peaks = combinePeaks(
rhythm.GetCurrentStrainPeaks().ToList(),
reading.GetCurrentStrainPeaks().ToList(),
colour.GetCurrentStrainPeaks().ToList(),
stamina.GetCurrentStrainPeaks().ToList()
);
var rhythmPeaks = rhythm.GetCurrentStrainPeaks().ToList(); if (peaks.Count == 0)
var readingPeaks = reading.GetCurrentStrainPeaks().ToList(); {
var colourPeaks = colour.GetCurrentStrainPeaks().ToList(); consistencyFactor = 0;
var staminaPeaks = stamina.GetCurrentStrainPeaks().ToList(); return 0;
}
double difficulty = 0;
double weight = 1;
foreach (double strain in peaks.OrderDescending())
{
difficulty += strain * weight;
weight *= 0.9;
}
List<double> hitObjectStrainPeaks = combinePeaks(
rhythm.GetObjectStrains().ToList(),
reading.GetObjectStrains().ToList(),
colour.GetObjectStrains().ToList(),
stamina.GetObjectStrains().ToList()
);
if (hitObjectStrainPeaks.Count == 0)
{
consistencyFactor = 0;
return 0;
}
// The average of the top 5% of strain peaks from hit objects.
double topAverageHitObjectStrain = hitObjectStrainPeaks.OrderDescending().Take(1 + hitObjectStrainPeaks.Count / 20).Average();
// Calculates a consistency factor as the sum of difficulty from hit objects compared to if every object were as hard as the hardest.
// The top average strain is used instead of the very hardest to prevent exceptionally hard objects lowering the factor.
consistencyFactor = hitObjectStrainPeaks.Sum() / (topAverageHitObjectStrain * hitObjectStrainPeaks.Count);
return difficulty;
}
/// <summary>
/// Combines lists of peak strains from multiple skills into a list of single peak strains for each section.
/// </summary>
private List<double> combinePeaks(List<double> rhythmPeaks, List<double> readingPeaks, List<double> colourPeaks, List<double> staminaPeaks)
{
var combinedPeaks = new List<double>();
for (int i = 0; i < colourPeaks.Count; i++) for (int i = 0; i < colourPeaks.Count; i++)
{ {
@@ -176,19 +226,10 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
// Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871). // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
// These sections will not contribute to the difficulty. // These sections will not contribute to the difficulty.
if (peak > 0) if (peak > 0)
peaks.Add(peak); combinedPeaks.Add(peak);
} }
double difficulty = 0; return combinedPeaks;
double weight = 1;
foreach (double strain in peaks.OrderDescending())
{
difficulty += strain * weight;
weight *= 0.9;
}
return difficulty;
} }
/// <summary> /// <summary>

View File

@@ -15,9 +15,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
[JsonProperty("accuracy")] [JsonProperty("accuracy")]
public double Accuracy { get; set; } public double Accuracy { get; set; }
[JsonProperty("effective_miss_count")]
public double EffectiveMissCount { get; set; }
[JsonProperty("estimated_unstable_rate")] [JsonProperty("estimated_unstable_rate")]
public double? EstimatedUnstableRate { get; set; } public double? EstimatedUnstableRate { get; set; }

View File

@@ -4,7 +4,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using osu.Framework.Audio.Track;
using osu.Framework.Extensions.IEnumerableExtensions; using osu.Framework.Extensions.IEnumerableExtensions;
using osu.Game.Rulesets.Difficulty; using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Difficulty.Utils; using osu.Game.Rulesets.Difficulty.Utils;
@@ -13,6 +12,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects; using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Rulesets.Taiko.Scoring; using osu.Game.Rulesets.Taiko.Scoring;
using osu.Game.Scoring; using osu.Game.Scoring;
using osu.Game.Utils;
namespace osu.Game.Rulesets.Taiko.Difficulty namespace osu.Game.Rulesets.Taiko.Difficulty
{ {
@@ -27,7 +27,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
private double clockRate; private double clockRate;
private double greatHitWindow; private double greatHitWindow;
private double effectiveMissCount; private double totalDifficultHits;
public TaikoPerformanceCalculator() public TaikoPerformanceCalculator()
: base(new TaikoRuleset()) : base(new TaikoRuleset())
@@ -43,9 +43,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh); countMeh = score.Statistics.GetValueOrDefault(HitResult.Meh);
countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss); countMiss = score.Statistics.GetValueOrDefault(HitResult.Miss);
var track = new TrackVirtual(10000); clockRate = ModUtils.CalculateRateWithMods(score.Mods);
score.Mods.OfType<IApplicableToTrack>().ForEach(m => m.ApplyToTrack(track));
clockRate = track.Rate;
var difficulty = score.BeatmapInfo!.Difficulty.Clone(); var difficulty = score.BeatmapInfo!.Difficulty.Clone();
@@ -56,70 +54,92 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate; greatHitWindow = hitWindows.WindowFor(HitResult.Great) / clockRate;
estimatedUnstableRate = computeDeviationUpperBound() * 10; estimatedUnstableRate = (countGreat == 0 || greatHitWindow <= 0)
? null
: computeDeviationUpperBound(countGreat / (double)totalHits) * 10;
// The effectiveMissCount is calculated by gaining a ratio for totalSuccessfulHits and increasing the miss penalty for shorter object counts lower than 1000. // Total difficult hits measures the total difficulty of a map based on its consistency factor.
if (totalSuccessfulHits > 0) totalDifficultHits = totalHits * taikoAttributes.ConsistencyFactor;
effectiveMissCount = Math.Max(1.0, 1000.0 / totalSuccessfulHits) * countMiss;
// Converts are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation. // Converts and the classic mod are detected and omitted from mod-specific bonuses due to the scope of current difficulty calculation.
bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1; bool isConvert = score.BeatmapInfo!.Ruleset.OnlineID != 1;
bool isClassic = score.Mods.Any(m => m is ModClassic);
double multiplier = 1.13; double difficultyValue = computeDifficultyValue(score, taikoAttributes, isConvert, isClassic) * 1.08;
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert) * 1.1;
if (score.Mods.Any(m => m is ModHidden) && !isConvert)
multiplier *= 1.075;
if (score.Mods.Any(m => m is ModEasy))
multiplier *= 0.950;
double difficultyValue = computeDifficultyValue(score, taikoAttributes);
double accuracyValue = computeAccuracyValue(score, taikoAttributes, isConvert);
double totalValue =
Math.Pow(
Math.Pow(difficultyValue, 1.1) +
Math.Pow(accuracyValue, 1.1), 1.0 / 1.1
) * multiplier;
return new TaikoPerformanceAttributes return new TaikoPerformanceAttributes
{ {
Difficulty = difficultyValue, Difficulty = difficultyValue,
Accuracy = accuracyValue, Accuracy = accuracyValue,
EffectiveMissCount = effectiveMissCount,
EstimatedUnstableRate = estimatedUnstableRate, EstimatedUnstableRate = estimatedUnstableRate,
Total = totalValue Total = difficultyValue + accuracyValue
}; };
} }
private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes) private double computeDifficultyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert, bool isClassic)
{ {
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating / 0.110) - 4.0; if (estimatedUnstableRate == null || totalDifficultHits == 0)
return 0;
// The estimated unstable rate for 100% accuracy, at which all rhythm difficulty has been played successfully.
double rhythmExpectedUnstableRate = computeDeviationUpperBound(1.0) * 10;
// The unstable rate at which it can be assumed all rhythm difficulty has been ignored.
// 0.8 represents 80% of total hits being greats, or 90% accuracy in-game
double rhythmMaximumUnstableRate = computeDeviationUpperBound(0.8) * 10;
// The fraction of star rating made up by rhythm difficulty, normalised to represent rhythm's perceived contribution to star rating.
double rhythmFactor = DifficultyCalculationUtils.ReverseLerp(attributes.RhythmDifficulty / attributes.StarRating, 0.15, 0.4);
// A penalty removing improperly played rhythm difficulty from star rating based on estimated unstable rate.
double rhythmPenalty = 1 - DifficultyCalculationUtils.Logistic(
estimatedUnstableRate.Value,
midpointOffset: (rhythmExpectedUnstableRate + rhythmMaximumUnstableRate) / 2,
multiplier: 10 / (rhythmMaximumUnstableRate - rhythmExpectedUnstableRate),
maxValue: 0.25 * Math.Pow(rhythmFactor, 3)
);
double baseDifficulty = 5 * Math.Max(1.0, attributes.StarRating * rhythmPenalty / 0.110) - 4.0;
double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0); double difficultyValue = Math.Min(Math.Pow(baseDifficulty, 3) / 69052.51, Math.Pow(baseDifficulty, 2.25) / 1250.0);
difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10); difficultyValue *= 1 + 0.10 * Math.Max(0, attributes.StarRating - 10);
double lengthBonus = 1 + 0.1 * Math.Min(1.0, totalHits / 1500.0); // Applies a bonus to maps with more total difficulty.
double lengthBonus = 1 + 0.25 * totalDifficultHits / (totalDifficultHits + 4000);
difficultyValue *= lengthBonus; difficultyValue *= lengthBonus;
difficultyValue *= Math.Pow(0.986, effectiveMissCount); // Scales miss penalty by the total difficult hits of a map, making misses more punishing on maps with less total difficulty.
double missPenalty = 0.97 + 0.03 * totalDifficultHits / (totalDifficultHits + 1500);
if (score.Mods.Any(m => m is ModEasy)) difficultyValue *= Math.Pow(missPenalty, countMiss);
difficultyValue *= 0.90;
if (score.Mods.Any(m => m is ModHidden)) if (score.Mods.Any(m => m is ModHidden))
difficultyValue *= 1.025; {
double hiddenBonus = isConvert ? 0.025 : 0.1;
// Hidden+flashlight plays are excluded from reading-based penalties to hidden.
if (!score.Mods.Any(m => m is ModFlashlight))
{
// A penalty is applied to the bonus for hidden on non-classic scores, as the playfield can be made wider to make fast reading easier.
if (!isClassic)
hiddenBonus *= 0.2;
// A penalty is applied to classic easy+hidden scores, as notes disappear later making fast reading easier.
if (score.Mods.Any(m => m is ModEasy) && isClassic)
hiddenBonus *= 0.5;
}
difficultyValue *= 1 + hiddenBonus;
}
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>)) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>))
difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus); difficultyValue *= Math.Max(1, 1.050 - Math.Min(attributes.MonoStaminaFactor / 50, 1) * lengthBonus);
if (estimatedUnstableRate == null)
return 0;
// Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps. // Scale accuracy more harshly on nearly-completely mono (single coloured) speed maps.
double accScalingExponent = 2 + attributes.MonoStaminaFactor; double monoAccScalingExponent = 2 + attributes.MonoStaminaFactor;
double accScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3); double monoAccScalingShift = 500 - 100 * (attributes.MonoStaminaFactor * 3);
return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(accScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), accScalingExponent); return difficultyValue * Math.Pow(DifficultyCalculationUtils.Erf(monoAccScalingShift / (Math.Sqrt(2) * estimatedUnstableRate.Value)), monoAccScalingExponent);
} }
private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert) private double computeAccuracyValue(ScoreInfo score, TaikoDifficultyAttributes attributes, bool isConvert)
@@ -127,13 +147,22 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
if (greatHitWindow <= 0 || estimatedUnstableRate == null) if (greatHitWindow <= 0 || estimatedUnstableRate == null)
return 0; return 0;
double accuracyValue = Math.Pow(70 / estimatedUnstableRate.Value, 1.1) * Math.Pow(attributes.StarRating, 0.4) * 100.0; double accuracyValue = 470 * Math.Pow(0.9885, estimatedUnstableRate.Value);
double lengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3)); // Scales up the bonus for lower unstable rate as star rating increases.
accuracyValue *= 1 + Math.Pow(50 / estimatedUnstableRate.Value, 2) * Math.Pow(attributes.StarRating, 2.8) / 600;
if (score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= 1.075;
// Applies a bonus to maps with more total difficulty, calculating this with a map's total hits and consistency factor.
accuracyValue *= 1 + 0.3 * totalDifficultHits / (totalDifficultHits + 4000);
// Applies a bonus to maps with more total memory required with HDFL.
double memoryLengthBonus = Math.Min(1.15, Math.Pow(totalHits / 1500.0, 0.3));
// Slight HDFL Bonus for accuracy. A clamp is used to prevent against negative values.
if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert) if (score.Mods.Any(m => m is ModFlashlight<TaikoHitObject>) && score.Mods.Any(m => m is ModHidden) && !isConvert)
accuracyValue *= Math.Max(1.0, 1.05 * lengthBonus); accuracyValue *= Math.Max(1.0, 1.05 * memoryLengthBonus);
return accuracyValue; return accuracyValue;
} }
@@ -143,17 +172,14 @@ namespace osu.Game.Rulesets.Taiko.Difficulty
/// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that /// and the hit judgements, assuming the player's mean hit error is 0. The estimation is consistent in that
/// two SS scores on the same map with the same settings will always return the same deviation. /// two SS scores on the same map with the same settings will always return the same deviation.
/// </summary> /// </summary>
private double? computeDeviationUpperBound() private double computeDeviationUpperBound(double accuracy)
{ {
if (countGreat == 0 || greatHitWindow <= 0)
return null;
const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed). const double z = 2.32634787404; // 99% critical value for the normal distribution (one-tailed).
double n = totalHits; double n = totalHits;
// Proportion of greats hit. // Proportion of greats hit.
double p = countGreat / n; double p = accuracy;
// We can be 99% confident that p is at least this value. // We can be 99% confident that p is at least this value.
double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4); double pLowerBound = (n * p + z * z / 2) / (n + z * z) - z / (n + z * z) * Math.Sqrt(n * p * (1 - p) + z * z / 4);

View File

@@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Rulesets.Taiko.Difficulty.Preprocessing;
namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
{
/// <summary>
/// Normalises deltaTime values for TaikoDifficultyHitObjects.
/// </summary>
public static class DeltaTimeNormaliser
{
/// <summary>
/// Combines deltaTime values that differ by at most <paramref name="marginOfError"/>
/// and replaces each value with the median of its range. This is used to reduce timing noise
/// and improve rhythm grouping consistency, especially for maps with inconsistent or 'off-snapped' timing.
/// </summary>
public static Dictionary<TaikoDifficultyHitObject, double> Normalise(
IReadOnlyList<TaikoDifficultyHitObject> hitObjects,
double marginOfError)
{
var deltaTimes = hitObjects.Select(h => h.DeltaTime).Distinct().OrderBy(d => d).ToList();
var sets = new List<List<double>>();
List<double>? current = null;
foreach (double value in deltaTimes)
{
// Add to the current group if within margin of error
if (current != null && Math.Abs(value - current[0]) <= marginOfError)
{
current.Add(value);
continue;
}
// Otherwise begin a new group
current = new List<double> { value };
sets.Add(current);
}
// Compute median for each group
var medianLookup = new Dictionary<double, double>();
foreach (var set in sets)
{
set.Sort();
int mid = set.Count / 2;
double median = set.Count % 2 == 1
? set[mid]
: (set[mid - 1] + set[mid]) / 2;
foreach (double v in set)
medianLookup[v] = median;
}
// Assign each hitobjects deltaTime the corresponding median value
return hitObjects.ToDictionary(
h => h,
h => medianLookup.TryGetValue(h.DeltaTime, out double median) ? median : h.DeltaTime
);
}
}
}

View File

@@ -8,6 +8,9 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
{ {
public static class IntervalGroupingUtils public static class IntervalGroupingUtils
{ {
// The margin of error when comparing intervals for grouping, or snapping intervals to a common value.
public const double MARGIN_OF_ERROR = 5.0;
public static List<List<T>> GroupByInterval<T>(IReadOnlyList<T> objects) where T : IHasInterval public static List<List<T>> GroupByInterval<T>(IReadOnlyList<T> objects) where T : IHasInterval
{ {
var groups = new List<List<T>>(); var groups = new List<List<T>>();
@@ -21,8 +24,6 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
private static List<T> createNextGroup<T>(IReadOnlyList<T> objects, ref int i) where T : IHasInterval private static List<T> createNextGroup<T>(IReadOnlyList<T> objects, ref int i) where T : IHasInterval
{ {
const double margin_of_error = 5;
// This never compares the first two elements in the group. // This never compares the first two elements in the group.
// This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329) // This sounds wrong but is apparently "as intended" (https://github.com/ppy/osu/pull/31636#discussion_r1942673329)
var groupedObjects = new List<T> { objects[i] }; var groupedObjects = new List<T> { objects[i] };
@@ -30,11 +31,11 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
for (; i < objects.Count - 1; i++) for (; i < objects.Count - 1; i++)
{ {
if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, margin_of_error)) if (!Precision.AlmostEquals(objects[i].Interval, objects[i + 1].Interval, MARGIN_OF_ERROR))
{ {
// When an interval change occurs, include the object with the differing interval in the case it increased // When an interval change occurs, include the object with the differing interval in the case it increased
// See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale. // See https://github.com/ppy/osu/pull/31636#discussion_r1942368372 for rationale.
if (objects[i + 1].Interval > objects[i].Interval + margin_of_error) if (objects[i + 1].Interval > objects[i].Interval + MARGIN_OF_ERROR)
{ {
groupedObjects.Add(objects[i]); groupedObjects.Add(objects[i]);
i++; i++;
@@ -49,7 +50,7 @@ namespace osu.Game.Rulesets.Taiko.Difficulty.Utils
// Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error. // Check if the last two objects in the object form a "flat" rhythm pattern within the specified margin of error.
// If true, add the current object to the group and increment the index to process the next object. // If true, add the current object to the group and increment the index to process the next object.
if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, margin_of_error)) if (objects.Count > 2 && i < objects.Count && Precision.AlmostEquals(objects[^1].Interval, objects[^2].Interval, MARGIN_OF_ERROR))
{ {
groupedObjects.Add(objects[i]); groupedObjects.Add(objects[i]);
i++; i++;

View File

@@ -34,7 +34,8 @@ namespace osu.Game.Rulesets.Taiko.Mods
return string.Empty; return string.Empty;
string format(string acronym, DifficultyBindable bindable, int digits) => $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(digits)}"; string format(string acronym, DifficultyBindable bindable, int digits)
=> $"{acronym}{bindable.Value!.Value.ToStandardFormattedString(digits)}";
} }
} }

View File

@@ -13,7 +13,8 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Argon
public const int SAMPLE_VOLUME_THRESHOLD_MEDIUM = 60; public const int SAMPLE_VOLUME_THRESHOLD_MEDIUM = 60;
public VolumeAwareHitSampleInfo(HitSampleInfo sampleInfo, bool isStrong = false) public VolumeAwareHitSampleInfo(HitSampleInfo sampleInfo, bool isStrong = false)
: base(sampleInfo.Name, isStrong ? BANK_STRONG : getBank(sampleInfo.Bank, sampleInfo.Name, sampleInfo.Volume), sampleInfo.Suffix, sampleInfo.Volume) : base(sampleInfo.Name, isStrong ? BANK_STRONG : getBank(sampleInfo.Bank, sampleInfo.Name, sampleInfo.Volume), sampleInfo.Suffix, sampleInfo.Volume,
sampleInfo.EditorAutoBank, sampleInfo.UseBeatmapSamples)
{ {
} }

View File

@@ -240,7 +240,7 @@ namespace osu.Game.Rulesets.Taiko.Skinning.Legacy
private class LegacyTaikoSampleInfo : HitSampleInfo private class LegacyTaikoSampleInfo : HitSampleInfo
{ {
public LegacyTaikoSampleInfo(HitSampleInfo sampleInfo) public LegacyTaikoSampleInfo(HitSampleInfo sampleInfo)
: base(sampleInfo.Name, sampleInfo.Bank, sampleInfo.Suffix, sampleInfo.Volume) : base(sampleInfo.Name, sampleInfo.Bank, sampleInfo.Suffix, sampleInfo.Volume, sampleInfo.EditorAutoBank, sampleInfo.UseBeatmapSamples)
{ {
} }

View File

@@ -11,6 +11,7 @@ using NUnit.Framework;
using osu.Framework.Audio.Track; using osu.Framework.Audio.Track;
using osu.Framework.Graphics.Textures; using osu.Framework.Graphics.Textures;
using osu.Framework.IO.Stores; using osu.Framework.IO.Stores;
using osu.Game.Audio;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
@@ -236,6 +237,31 @@ namespace osu.Game.Tests.Beatmaps.Formats
Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position))); Is.EquivalentTo(originalSlider.Path.ControlPoints.Select(p => p.Position)));
} }
[Test]
public void TestEncodeCustomSampleBanks()
{
var beatmap = new Beatmap
{
HitObjects =
{
new HitCircle { StartTime = 100, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL)] },
new HitCircle { StartTime = 200, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL, useBeatmapSamples: true)] },
new HitCircle { StartTime = 300, Samples = [new HitSampleInfo(HitSampleInfo.HIT_NORMAL, suffix: "3", useBeatmapSamples: true)] },
}
};
var decodedAfterEncode = decodeFromLegacy(encodeToLegacy((beatmap, new TestLegacySkin(beatmaps_resource_store, string.Empty))), string.Empty);
Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.beatmap.HitObjects[0].Samples[0].UseBeatmapSamples, Is.False);
Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].Suffix, Is.Null);
Assert.That(decodedAfterEncode.beatmap.HitObjects[1].Samples[0].UseBeatmapSamples, Is.True);
Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].Suffix, Is.EqualTo("3"));
Assert.That(decodedAfterEncode.beatmap.HitObjects[2].Samples[0].UseBeatmapSamples, Is.True);
}
private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b) private bool areComboColoursEqual(IHasComboColours a, IHasComboColours b)
{ {
// equal to null, no need to SequenceEqual // equal to null, no need to SequenceEqual

View File

@@ -59,7 +59,15 @@ namespace osu.Game.Tests.Beatmaps.IO
// Ensure importer encoding is correct // Ensure importer encoding is correct
AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"fractional-coordinates.olz")); AddStep("import beatmap", () => beatmap = importBeatmapFromArchives(@"fractional-coordinates.olz"));
AddAssert("hit object has fractional position", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(383.99997).Within(0.00001)); AddAssert("second slider has fractional position",
() => ((IHasXPosition)beatmap.Beatmap.HitObjects[1]).X,
() => Is.EqualTo(-3.0517578E-05).Within(0.00001));
AddAssert("second slider path has fractional coordinates",
() => ((IHasPath)beatmap.Beatmap.HitObjects[1]).Path.ControlPoints[1].Position.X,
() => Is.EqualTo(191.999939).Within(0.00001));
AddAssert("second hit circle has fractional position",
() => ((IHasYPosition)beatmap.Beatmap.HitObjects[3]).Y,
() => Is.EqualTo(383.99997).Within(0.00001));
// Ensure exporter legacy conversion is correct // Ensure exporter legacy conversion is correct
AddStep("export", () => AddStep("export", () =>
@@ -71,7 +79,15 @@ namespace osu.Game.Tests.Beatmaps.IO
}); });
AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream)); AddStep("import beatmap again", () => beatmap = importBeatmapFromStream(outStream));
AddAssert("hit object is snapped", () => ((IHasYPosition)beatmap.Beatmap.HitObjects[1]).Y, () => Is.EqualTo(384).Within(0.00001)); AddAssert("second slider is snapped",
() => ((IHasXPosition)beatmap.Beatmap.HitObjects[1]).X,
() => Is.EqualTo(0).Within(0.00001));
AddAssert("second slider path is snapped",
() => ((IHasPath)beatmap.Beatmap.HitObjects[1]).Path.ControlPoints[1].Position.X,
() => Is.EqualTo(192).Within(0.00001));
AddAssert("second hit circle is snapped",
() => ((IHasYPosition)beatmap.Beatmap.HitObjects[3]).Y,
() => Is.EqualTo(384).Within(0.00001));
} }
[Test] [Test]

View File

@@ -24,7 +24,7 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets.AvailableRulesets.Count());
Assert.AreEqual(4, realm.Realm.All<RulesetInfo>().Count()); Assert.AreEqual(4, realm.Realm.All<RulesetInfo>().Count());
@@ -36,8 +36,8 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
var rulesets2 = new RealmRulesetStore(realm, storage); using var rulesets2 = new RealmRulesetStore(realm, storage);
Assert.AreEqual(4, rulesets.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets.AvailableRulesets.Count());
Assert.AreEqual(4, rulesets2.AvailableRulesets.Count()); Assert.AreEqual(4, rulesets2.AvailableRulesets.Count());
@@ -52,7 +52,7 @@ namespace osu.Game.Tests.Database
{ {
RunTestWithRealm((realm, storage) => RunTestWithRealm((realm, storage) =>
{ {
var rulesets = new RealmRulesetStore(realm, storage); using var rulesets = new RealmRulesetStore(realm, storage);
Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged); Assert.IsFalse(rulesets.AvailableRulesets.First().IsManaged);
Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged); Assert.IsFalse(rulesets.GetRuleset(0)?.IsManaged);
@@ -79,7 +79,7 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
_ = new RealmRulesetStore(realm, storage); using var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
}); });
@@ -104,13 +104,13 @@ namespace osu.Game.Tests.Database
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
// Availability is updated on construction of a RealmRulesetStore // Availability is updated on construction of a RealmRulesetStore
_ = new RealmRulesetStore(realm, storage); using var _ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.False);
// Simulate the ruleset getting updated // Simulate the ruleset getting updated
LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION; LoadTestRuleset.Version = Ruleset.CURRENT_RULESET_API_VERSION;
_ = new RealmRulesetStore(realm, storage); using var __ = new RealmRulesetStore(realm, storage);
Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True); Assert.That(realm.Run(r => r.Find<RulesetInfo>(rulesetShortName)!.Available), Is.True);
}); });

View File

@@ -17,6 +17,7 @@ namespace osu.Game.Tests.Extensions
[TestCase(0, true, 0, ExpectedResult = "0%")] [TestCase(0, true, 0, ExpectedResult = "0%")]
[TestCase(1, true, 0, ExpectedResult = "1%")] [TestCase(1, true, 0, ExpectedResult = "1%")]
[TestCase(50, true, 0, ExpectedResult = "50%")] [TestCase(50, true, 0, ExpectedResult = "50%")]
[SetCulture("")] // invariant culture
public string TestInteger(int input, bool percent, int decimalDigits) public string TestInteger(int input, bool percent, int decimalDigits)
{ {
return input.ToStandardFormattedString(decimalDigits, percent); return input.ToStandardFormattedString(decimalDigits, percent);
@@ -39,6 +40,7 @@ namespace osu.Game.Tests.Extensions
[TestCase(0.48333, true, 2, ExpectedResult = "48%")] [TestCase(0.48333, true, 2, ExpectedResult = "48%")]
[TestCase(0.48333, true, 4, ExpectedResult = "48.33%")] [TestCase(0.48333, true, 4, ExpectedResult = "48.33%")]
[TestCase(1, true, 0, ExpectedResult = "100%")] [TestCase(1, true, 0, ExpectedResult = "100%")]
[SetCulture("")] // invariant culture
public string TestDouble(double input, bool percent, int decimalDigits) public string TestDouble(double input, bool percent, int decimalDigits)
{ {
return input.ToStandardFormattedString(decimalDigits, percent); return input.ToStandardFormattedString(decimalDigits, percent);
@@ -46,9 +48,12 @@ namespace osu.Game.Tests.Extensions
[Test] [Test]
[SetCulture("fr-FR")] [SetCulture("fr-FR")]
public void TestCultureInsensitivity() [TestCase(0.4, true, 2, ExpectedResult = "40%")]
[TestCase(1e-6, false, 6, ExpectedResult = "0,000001")]
[TestCase(0.48333, true, 4, ExpectedResult = "48,33%")]
public string TestCultureSensitivity(double input, bool percent, int decimalDigits)
{ {
Assert.That(0.4.ToStandardFormattedString(maxDecimalDigits: 2, asPercentage: true), Is.EqualTo("40%")); return input.ToStandardFormattedString(decimalDigits, percent);
} }
} }
} }

View File

@@ -299,6 +299,23 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(filtered, carouselItem.Filtered.Value); Assert.AreEqual(filtered, carouselItem.Filtered.Value);
} }
[Test]
[TestCase("artist")]
[TestCase("unicode")]
public void TestCriteriaNotMatchingArtist(string excludedTerm)
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
Artist = new FilterCriteria.OptionalTextFilter { SearchTerm = excludedTerm, ExcludeTerm = true }
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.True(carouselItem.Filtered.Value);
}
[TestCase("simple", false)] [TestCase("simple", false)]
[TestCase("\"style/clean\"", false)] [TestCase("\"style/clean\"", false)]
[TestCase("\"style/clean\"!", false)] [TestCase("\"style/clean\"!", false)]
@@ -350,6 +367,41 @@ namespace osu.Game.Tests.NonVisual.Filtering
Assert.AreEqual(true, carouselItem.Filtered.Value); Assert.AreEqual(true, carouselItem.Filtered.Value);
} }
[Test]
public void TestCriteriaMatchingTagExcluded()
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
UserTags =
[
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!", ExcludeTerm = true },
]
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.AreEqual(true, carouselItem.Filtered.Value);
}
[Test]
public void TestCriteriaOneTagIncludedAndOneTagExcluded()
{
var beatmap = getExampleBeatmap();
var criteria = new FilterCriteria
{
UserTags =
[
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"song representation/simple\"!" },
new FilterCriteria.OptionalTextFilter { SearchTerm = "\"style/clean\"!", ExcludeTerm = true }
]
};
var carouselItem = new CarouselBeatmap(beatmap);
carouselItem.Filter(criteria);
Assert.AreEqual(true, carouselItem.Filtered.Value);
}
[Test] [Test]
public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive() public void TestBeatmapMustHaveAtLeastOneTagIfUserTagFilterActive()
{ {

View File

@@ -16,6 +16,7 @@ using osu.Framework.Platform;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Beatmaps; using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats; using osu.Game.Beatmaps.Formats;
using osu.Game.Configuration;
using osu.Game.Database; using osu.Game.Database;
using osu.Game.IO; using osu.Game.IO;
using osu.Game.IO.Archives; using osu.Game.IO.Archives;
@@ -206,7 +207,7 @@ namespace osu.Game.Tests.Online
{ {
} }
protected override BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm) protected override BeatmapImporter CreateBeatmapImporter(Storage storage, RealmAccess realm, OsuConfigManager? config = null)
{ {
return new TestBeatmapImporter(this, storage, realm); return new TestBeatmapImporter(this, storage, realm);
} }
@@ -216,7 +217,7 @@ namespace osu.Game.Tests.Online
private readonly TestBeatmapManager testBeatmapManager; private readonly TestBeatmapManager testBeatmapManager;
public TestBeatmapImporter(TestBeatmapManager testBeatmapManager, Storage storage, RealmAccess databaseAccess) public TestBeatmapImporter(TestBeatmapManager testBeatmapManager, Storage storage, RealmAccess databaseAccess)
: base(storage, databaseAccess) : base(storage, databaseAccess, null)
{ {
this.testBeatmapManager = testBeatmapManager; this.testBeatmapManager = testBeatmapManager;
} }
@@ -256,4 +257,4 @@ namespace osu.Game.Tests.Online
protected override string Target => string.Empty; protected override string Target => string.Empty;
} }
} }
} }

View File

@@ -149,6 +149,8 @@ namespace osu.Game.Tests.Rulesets
public IBindable<double> AggregateFrequency => throw new NotImplementedException(); public IBindable<double> AggregateFrequency => throw new NotImplementedException();
public IBindable<double> AggregateTempo => throw new NotImplementedException(); public IBindable<double> AggregateTempo => throw new NotImplementedException();
public void Invalidate(string name) => throw new NotImplementedException();
public int PlaybackConcurrency { get; set; } public int PlaybackConcurrency { get; set; }
public void AddExtension(string extension) => throw new NotImplementedException(); public void AddExtension(string extension) => throw new NotImplementedException();

View File

@@ -1,209 +1,209 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // // See the LICENCE file in the repository root for full licence text.
#nullable disable // #nullable disable
using System; // using System;
using System.Collections.Generic; // using System.Collections.Generic;
using System.Linq; // using System.Linq;
using NUnit.Framework; // using NUnit.Framework;
using osu.Framework.Allocation; // using osu.Framework.Allocation;
using osu.Framework.Graphics; // using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; // using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Rendering; // using osu.Framework.Graphics.Rendering;
using osu.Framework.Graphics.Textures; // using osu.Framework.Graphics.Textures;
using osu.Game.Configuration; // using osu.Game.Configuration;
using osu.Game.Graphics.Backgrounds; // using osu.Game.Graphics.Backgrounds;
using osu.Game.Online.API; // using osu.Game.Online.API;
using osu.Game.Online.API.Requests; // using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses; // using osu.Game.Online.API.Requests.Responses;
namespace osu.Game.Tests.Visual.Background // namespace osu.Game.Tests.Visual.Background
{ // {
public partial class TestSceneSeasonalBackgroundLoader : ScreenTestScene // public partial class TestSceneSeasonalBackgroundLoader : ScreenTestScene
{ // {
[Resolved] // [Resolved]
private OsuConfigManager config { get; set; } // private OsuConfigManager config { get; set; }
[Resolved] // [Resolved]
private SessionStatics statics { get; set; } // private SessionStatics statics { get; set; }
private DummyAPIAccess dummyAPI => (DummyAPIAccess)API; // private DummyAPIAccess dummyAPI => (DummyAPIAccess)API;
private LookupLoggingTextureStore textureStore; // private LookupLoggingTextureStore textureStore;
private SeasonalBackgroundLoader backgroundLoader; // private SeasonalBackgroundLoader backgroundLoader;
private Container backgroundContainer; // private Container backgroundContainer;
// in real usages these would be online URLs, but correct execution of this test // // in real usages these would be online URLs, but correct execution of this test
// shouldn't be coupled to existence of online assets. // // shouldn't be coupled to existence of online assets.
private static readonly List<string> seasonal_background_urls = new List<string> // private static readonly List<string> seasonal_background_urls = new List<string>
{ // {
"Backgrounds/bg2", // "Backgrounds/bg2",
"Backgrounds/bg4", // "Backgrounds/bg4",
"Backgrounds/bg3" // "Backgrounds/bg3"
}; // };
protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent) // protected override IReadOnlyDependencyContainer CreateChildDependencies(IReadOnlyDependencyContainer parent)
{ // {
var dependencies = new DependencyContainer(base.CreateChildDependencies(parent)); // var dependencies = new DependencyContainer(base.CreateChildDependencies(parent));
textureStore = new LookupLoggingTextureStore(dependencies.Get<IRenderer>()); // textureStore = new LookupLoggingTextureStore(dependencies.Get<IRenderer>());
dependencies.CacheAs(typeof(LargeTextureStore), textureStore); // dependencies.CacheAs(typeof(LargeTextureStore), textureStore);
return dependencies; // return dependencies;
} // }
[BackgroundDependencyLoader] // [BackgroundDependencyLoader]
private void load(LargeTextureStore wrappedStore) // private void load(LargeTextureStore wrappedStore)
{ // {
textureStore.AddStore(wrappedStore); // textureStore.AddStore(wrappedStore);
Child = new DependencyProvidingContainer // Child = new DependencyProvidingContainer
{ // {
CachedDependencies = new (Type, object)[] // CachedDependencies = new (Type, object)[]
{ // {
(typeof(LargeTextureStore), textureStore) // (typeof(LargeTextureStore), textureStore)
}, // },
Child = backgroundContainer = new Container // Child = backgroundContainer = new Container
{ // {
RelativeSizeAxes = Axes.Both // RelativeSizeAxes = Axes.Both
} // }
}; // };
} // }
[SetUp] // [SetUp]
public void SetUp() => Schedule(() => // public void SetUp() => Schedule(() =>
{ // {
// reset API response in statics to avoid test crosstalk. // // reset API response in statics to avoid test crosstalk.
statics.SetValue<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null); // statics.SetValue<APISeasonalBackgrounds>(Static.SeasonalBackgrounds, null);
textureStore.PerformedLookups.Clear(); // textureStore.PerformedLookups.Clear();
dummyAPI.SetState(APIState.Online); // dummyAPI.SetState(APIState.Online);
backgroundContainer.Clear(); // backgroundContainer.Clear();
}); // });
[TestCase(-5)] // [TestCase(-5)]
[TestCase(5)] // [TestCase(5)]
public void TestAlwaysSeasonal(int daysOffset) // public void TestAlwaysSeasonal(int daysOffset)
{ // {
registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); // registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset));
setSeasonalBackgroundMode(SeasonalBackgroundMode.Always); // setSeasonalBackgroundMode(SeasonalBackgroundMode.Always);
createLoader(); // createLoader();
for (int i = 0; i < 4; ++i) // for (int i = 0; i < 4; ++i)
loadNextBackground(); // loadNextBackground();
AddAssert("all backgrounds cycled", () => new HashSet<string>(textureStore.PerformedLookups).SetEquals(seasonal_background_urls)); // AddAssert("all backgrounds cycled", () => new HashSet<string>(textureStore.PerformedLookups).SetEquals(seasonal_background_urls));
} // }
[TestCase(-5)] // [TestCase(-5)]
[TestCase(5)] // [TestCase(5)]
public void TestNeverSeasonal(int daysOffset) // public void TestNeverSeasonal(int daysOffset)
{ // {
registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset)); // registerBackgroundsResponse(DateTimeOffset.Now.AddDays(daysOffset));
setSeasonalBackgroundMode(SeasonalBackgroundMode.Never); // setSeasonalBackgroundMode(SeasonalBackgroundMode.Never);
createLoader(); // createLoader();
assertNoBackgrounds(); // assertNoBackgrounds();
} // }
[Test] // [Test]
public void TestSometimesInSeason() // public void TestSometimesInSeason()
{ // {
registerBackgroundsResponse(DateTimeOffset.Now.AddDays(5)); // registerBackgroundsResponse(DateTimeOffset.Now.AddDays(5));
setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); // setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes);
createLoader(); // createLoader();
assertAnyBackground(); // assertAnyBackground();
} // }
[Test] // [Test]
public void TestSometimesOutOfSeason() // public void TestSometimesOutOfSeason()
{ // {
registerBackgroundsResponse(DateTimeOffset.Now.AddDays(-10)); // registerBackgroundsResponse(DateTimeOffset.Now.AddDays(-10));
setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes); // setSeasonalBackgroundMode(SeasonalBackgroundMode.Sometimes);
createLoader(); // createLoader();
assertNoBackgrounds(); // assertNoBackgrounds();
} // }
private void registerBackgroundsResponse(DateTimeOffset endDate) // private void registerBackgroundsResponse(DateTimeOffset endDate)
=> AddStep("setup request handler", () => // => AddStep("setup request handler", () =>
{ // {
dummyAPI.HandleRequest = request => // dummyAPI.HandleRequest = request =>
{ // {
if (dummyAPI.State.Value != APIState.Online || !(request is GetSeasonalBackgroundsRequest backgroundsRequest)) // if (dummyAPI.State.Value != APIState.Online || !(request is GetSeasonalBackgroundsRequest backgroundsRequest))
return false; // return false;
backgroundsRequest.TriggerSuccess(new APISeasonalBackgrounds // backgroundsRequest.TriggerSuccess(new APISeasonalBackgrounds
{ // {
Backgrounds = seasonal_background_urls.Select(url => new APISeasonalBackground { Url = url }).ToList(), // Backgrounds = seasonal_background_urls.Select(url => new APISeasonalBackground { Url = url }).ToList(),
EndDate = endDate // EndDate = endDate
}); // });
return true; // return true;
}; // };
}); // });
private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode) // private void setSeasonalBackgroundMode(SeasonalBackgroundMode mode)
=> AddStep($"set seasonal mode to {mode}", () => config.SetValue(OsuSetting.SeasonalBackgroundMode, mode)); // => AddStep($"set seasonal mode to {mode}", () => config.SetValue(OsuSetting.SeasonalBackgroundMode, mode));
private void createLoader() // private void createLoader()
=> AddStep("create loader", () => // => AddStep("create loader", () =>
{ // {
if (backgroundLoader != null) // if (backgroundLoader != null)
Remove(backgroundLoader, true); // Remove(backgroundLoader, true);
Add(backgroundLoader = new SeasonalBackgroundLoader()); // Add(backgroundLoader = new SeasonalBackgroundLoader());
}); // });
private void loadNextBackground() // private void loadNextBackground()
{ // {
SeasonalBackground previousBackground = null; // SeasonalBackground previousBackground = null;
SeasonalBackground background = null; // SeasonalBackground background = null;
AddStep("create next background", () => // AddStep("create next background", () =>
{ // {
previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault(); // previousBackground = (SeasonalBackground)backgroundContainer.SingleOrDefault();
background = backgroundLoader.LoadNextBackground(); // background = backgroundLoader.LoadNextBackground();
if (background != null) // if (background != null)
LoadComponentAsync(background, bg => backgroundContainer.Child = bg); // LoadComponentAsync(background, bg => backgroundContainer.Child = bg);
}); // });
AddUntilStep("background loaded", () => background.IsLoaded); // AddUntilStep("background loaded", () => background.IsLoaded);
AddAssert("background is different", () => !background.Equals(previousBackground)); // AddAssert("background is different", () => !background.Equals(previousBackground));
} // }
private void assertAnyBackground() // private void assertAnyBackground()
{ // {
loadNextBackground(); // loadNextBackground();
AddAssert("background looked up", () => textureStore.PerformedLookups.Any()); // AddAssert("background looked up", () => textureStore.PerformedLookups.Any());
} // }
private void assertNoBackgrounds() // private void assertNoBackgrounds()
{ // {
AddAssert("no background available", () => backgroundLoader.LoadNextBackground() == null); // AddAssert("no background available", () => backgroundLoader.LoadNextBackground() == null);
AddAssert("no lookups performed", () => !textureStore.PerformedLookups.Any()); // AddAssert("no lookups performed", () => !textureStore.PerformedLookups.Any());
} // }
private class LookupLoggingTextureStore : LargeTextureStore // private class LookupLoggingTextureStore : LargeTextureStore
{ // {
public List<string> PerformedLookups { get; } = new List<string>(); // public List<string> PerformedLookups { get; } = new List<string>();
public LookupLoggingTextureStore(IRenderer renderer) // public LookupLoggingTextureStore(IRenderer renderer)
: base(renderer) // : base(renderer)
{ // {
} // }
public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT) // public override Texture Get(string name, WrapMode wrapModeS, WrapMode wrapModeT)
{ // {
PerformedLookups.Add(name); // PerformedLookups.Add(name);
return base.Get(name, wrapModeS, wrapModeT); // return base.Get(name, wrapModeS, wrapModeT);
} // }
} // }
} // }
} // }

View File

@@ -50,7 +50,7 @@ namespace osu.Game.Tests.Visual.Components
}; };
for (int i = 1; i <= 100; i++) for (int i = 1; i <= 100; i++)
((DummyAPIAccess)API).Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } }); ((DummyAPIAccess)API).LocalUserState.Friends.Add(new APIRelation { TargetID = i, TargetUser = new APIUser { Username = $"Friend {i}" } });
}); });
[Test] [Test]
@@ -75,7 +75,9 @@ namespace osu.Game.Tests.Visual.Components
}); });
AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible)); AddUntilStep("chat overlay opened", () => chatOverlay.State.Value, () => Is.EqualTo(Visibility.Visible));
AddUntilStep("user channel selected", () => channelManager.CurrentChannel.Value.Name, () => Is.EqualTo(((DummyAPIAccess)API).Friends[0].TargetUser!.Username)); AddUntilStep("user channel selected",
() => channelManager.CurrentChannel.Value.Name,
() => Is.EqualTo(((DummyAPIAccess)API).LocalUserState.Friends[0].TargetUser!.Username));
} }
[Test] [Test]

View File

@@ -68,6 +68,25 @@ namespace osu.Game.Tests.Visual.Editing
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f)); progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.08f));
}, 0, true); }, 0, true);
}); });
AddStep("increase progress slowly then fail", () =>
{
incrementingProgress = 0;
ScheduledDelegate? task = null;
task = Scheduler.AddDelayed(() =>
{
if (incrementingProgress >= 1)
{
progress.SetFailed("nope");
// ReSharper disable once AccessToModifiedClosure
task?.Cancel();
return;
}
progress.SetInProgress(incrementingProgress += RNG.NextSingle(0.001f));
}, 0, true);
});
AddUntilStep("wait for completed", () => incrementingProgress >= 1); AddUntilStep("wait for completed", () => incrementingProgress >= 1);
AddStep("completed", () => progress.SetCompleted()); AddStep("completed", () => progress.SetCompleted());

View File

@@ -183,6 +183,9 @@ namespace osu.Game.Tests.Visual.Gameplay
AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All); AddStep("Show all judgements", () => counterDisplay.Mode.Value = ArgonJudgementCounterDisplay.DisplayMode.All);
AddWaitStep("wait some", 2); AddWaitStep("wait some", 2);
AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Last().Alpha == 1); AddAssert("Check all visible", () => counterDisplay.CounterFlow.ChildrenOfType<ArgonJudgementCounter>().Last().Alpha == 1);
AddToggleStep("toggle wireframe display", t => counterDisplay.WireframeOpacity.Value = t ? 0.3f : 0);
AddStep("Set direction vertical", () => counterDisplay.FlowDirection.Value = Direction.Vertical);
AddStep("Set direction horizontal", () => counterDisplay.FlowDirection.Value = Direction.Horizontal);
} }
private int hiddenCount() private int hiddenCount()

View File

@@ -90,8 +90,8 @@ namespace osu.Game.Tests.Visual.Gameplay
var api = (DummyAPIAccess)API; var api = (DummyAPIAccess)API;
api.Friends.Clear(); api.LocalUserState.Friends.Clear();
api.Friends.Add(new APIRelation api.LocalUserState.Friends.Add(new APIRelation
{ {
Mutual = true, Mutual = true,
RelationType = RelationType.Friend, RelationType = RelationType.Friend,
@@ -105,7 +105,7 @@ namespace osu.Game.Tests.Visual.Gameplay
new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 }, new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000, Accuracy = 0.99, MaxCombo = 999 },
new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 }, new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 },
new ScoreInfo { User = friend, TotalScore = 700_000, Accuracy = 0.88, MaxCombo = 777 }, new ScoreInfo { User = friend, TotalScore = 700_000, Accuracy = 0.88, MaxCombo = 777 },
}, 3, null); }, scoresRequested: 50, totalScores: 3, null);
}); });
createLeaderboard(); createLeaderboard();
@@ -129,8 +129,8 @@ namespace osu.Game.Tests.Visual.Gameplay
var api = (DummyAPIAccess)API; var api = (DummyAPIAccess)API;
api.Friends.Clear(); api.LocalUserState.Friends.Clear();
api.Friends.Add(new APIRelation api.LocalUserState.Friends.Add(new APIRelation
{ {
Mutual = true, Mutual = true,
RelationType = RelationType.Friend, RelationType = RelationType.Friend,
@@ -144,7 +144,7 @@ namespace osu.Game.Tests.Visual.Gameplay
new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000_000, Accuracy = 0.99, MaxCombo = 999999 }, new ScoreInfo { User = new APIUser { Username = "Top", Id = 2 }, TotalScore = 900_000_000, Accuracy = 0.99, MaxCombo = 999999 },
new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000_000, Accuracy = 0.9, MaxCombo = 888888 }, new ScoreInfo { User = new APIUser { Username = "Second", Id = 14 }, TotalScore = 800_000_000, Accuracy = 0.9, MaxCombo = 888888 },
new ScoreInfo { User = friend, TotalScore = 700_000_000, Accuracy = 0.88, MaxCombo = 777777 }, new ScoreInfo { User = friend, TotalScore = 700_000_000, Accuracy = 0.88, MaxCombo = 777777 },
}, 3, null); }, scoresRequested: 50, totalScores: 3, null);
}); });
createLeaderboard(); createLeaderboard();
@@ -170,7 +170,7 @@ namespace osu.Game.Tests.Visual.Gameplay
scores.Add(new ScoreInfo { User = new APIUser { Username = $"Player {i + 1}" }, TotalScore = RNG.Next(700_000, 1_000_000) }); scores.Add(new ScoreInfo { User = new APIUser { Username = $"Player {i + 1}" }, TotalScore = RNG.Next(700_000, 1_000_000) });
// this is dodgy but anything less dodgy is a lot of work // this is dodgy but anything less dodgy is a lot of work
((Bindable<LeaderboardScores?>)leaderboardManager.Scores).Value = LeaderboardScores.Success(scores, scores.Count, null); ((Bindable<LeaderboardScores?>)leaderboardManager.Scores).Value = LeaderboardScores.Success(scores, scoresRequested: 50, scores.Count, null);
gameplayState.ScoreProcessor.TotalScore.Value = 0; gameplayState.ScoreProcessor.TotalScore.Value = 0;
}); });
@@ -208,7 +208,7 @@ namespace osu.Game.Tests.Visual.Gameplay
new ScoreInfo { User = new APIUser { Username = "smoogipoo", Id = 1040328 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 }, new ScoreInfo { User = new APIUser { Username = "smoogipoo", Id = 1040328 }, TotalScore = 800_000, Accuracy = 0.9, MaxCombo = 888 },
new ScoreInfo { User = new APIUser { Username = "flyte", Id = 3103765 }, TotalScore = 700_000, Accuracy = 0.9, MaxCombo = 888 }, new ScoreInfo { User = new APIUser { Username = "flyte", Id = 3103765 }, TotalScore = 700_000, Accuracy = 0.9, MaxCombo = 888 },
new ScoreInfo { User = new APIUser { Username = "frenzibyte", Id = 14210502 }, TotalScore = 600_000, Accuracy = 0.9, MaxCombo = 777 }, new ScoreInfo { User = new APIUser { Username = "frenzibyte", Id = 14210502 }, TotalScore = 600_000, Accuracy = 0.9, MaxCombo = 777 },
}, 4, null); }, scoresRequested: 50, totalScores: 4, null);
}); });
createLeaderboard(); createLeaderboard();
@@ -223,7 +223,7 @@ namespace osu.Game.Tests.Visual.Gameplay
((Bindable<LeaderboardScores?>)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[] ((Bindable<LeaderboardScores?>)leaderboardManager.Scores).Value = LeaderboardScores.Success(new[]
{ {
new ScoreInfo { User = new APIUser { Username = "Quit", Id = 3 }, TotalScore = 100_000, Accuracy = 0.99, MaxCombo = 999 }, new ScoreInfo { User = new APIUser { Username = "Quit", Id = 3 }, TotalScore = 100_000, Accuracy = 0.99, MaxCombo = 999 },
}, 1, null); }, scoresRequested: 50, totalScores: 1, null);
}); });
createLeaderboard(); createLeaderboard();

View File

@@ -257,6 +257,14 @@ namespace osu.Game.Tests.Visual.Gameplay
AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null)); AddUntilStep("score in database", () => Realm.Run(r => r.Find<ScoreInfo>(Player.Score.ScoreInfo.ID) != null));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private class CustomRuleset : OsuRuleset, ILegacyRuleset private class CustomRuleset : OsuRuleset, ILegacyRuleset
{ {
public override string Description => "custom"; public override string Description => "custom";

View File

@@ -316,6 +316,26 @@ namespace osu.Game.Tests.Visual.Gameplay
AddAssert("ensure no submission", () => Player.SubmittedScore == null); AddAssert("ensure no submission", () => Player.SubmittedScore == null);
} }
[Test]
public void TestNoSubmissionOnLocallyModifiedBeatmapWithOnlineId()
{
prepareTestAPI(true);
createPlayerTest(false, r =>
{
var beatmap = createTestBeatmap(r);
beatmap.BeatmapInfo.Status = BeatmapOnlineStatus.LocallyModified;
return beatmap;
});
AddUntilStep("wait for token request", () => Player.TokenCreationRequested);
addFakeHit();
AddStep("exit", () => Player.Exit());
AddAssert("ensure no submission", () => Player.SubmittedScore == null);
}
[TestCase(null)] [TestCase(null)]
[TestCase(10)] [TestCase(10)]
public void TestNoSubmissionOnCustomRuleset(int? rulesetId) public void TestNoSubmissionOnCustomRuleset(int? rulesetId)

View File

@@ -35,7 +35,9 @@ namespace osu.Game.Tests.Visual.Gameplay
protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset(); protected override Ruleset CreateRulesetForSkinProvider() => new OsuRuleset();
protected override Drawable CreateDefaultImplementation() => new ArgonKeyCounterDisplay(); protected override Drawable CreateArgonImplementation() => new ArgonKeyCounterDisplay();
protected override Drawable CreateDefaultImplementation() => new DefaultKeyCounterDisplay();
protected override Drawable CreateLegacyImplementation() => new LegacyKeyCounterDisplay(); protected override Drawable CreateLegacyImplementation() => new LegacyKeyCounterDisplay();
} }

View File

@@ -37,7 +37,8 @@ namespace osu.Game.Tests.Visual.Gameplay
TotalScore = 10_000 * (100 - i), TotalScore = 10_000 * (100 - i),
Position = i, Position = i,
}).ToArray(), }).ToArray(),
1337, scoresRequested: 100,
totalScores: 100,
null null
); );
}); });
@@ -84,7 +85,8 @@ namespace osu.Game.Tests.Visual.Gameplay
TotalScore = 600_000 + 10_000 * (40 - i), TotalScore = 600_000 + 10_000 * (40 - i),
Position = i, Position = i,
}).ToArray(), }).ToArray(),
1337, scoresRequested: 50,
totalScores: 40,
null null
); );
}); });
@@ -131,7 +133,8 @@ namespace osu.Game.Tests.Visual.Gameplay
TotalScore = 500_000 + 10_000 * (50 - i), TotalScore = 500_000 + 10_000 * (50 - i),
Position = i Position = i
}).ToArray(), }).ToArray(),
1337, scoresRequested: 50,
totalScores: 1337,
new ScoreInfo { TotalScore = 200_000 } new ScoreInfo { TotalScore = 200_000 }
); );
}); });

View File

@@ -0,0 +1,211 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Graphics;
using osu.Game.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.OnlinePlay;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectGrid : OnlinePlayTestScene
{
private MultiplayerPlaylistItem[] items = null!;
private BeatmapSelectGrid grid = null!;
[Resolved]
private BeatmapManager beatmapManager { get; set; } = null!;
[BackgroundDependencyLoader]
private void load()
{
var beatmaps = beatmapManager.GetAllUsableBeatmapSets()
.SelectMany(it => it.Beatmaps)
.Take(50)
.ToArray();
if (beatmaps.Length > 0)
{
items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = beatmaps[i % beatmaps.Length].OnlineID,
StarRating = i / 10.0,
}).ToArray();
}
else
{
items = Enumerable.Range(1, 50).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
}
}
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add grid", () => Child = grid = new BeatmapSelectGrid
{
RelativeSizeAxes = Axes.Both,
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Scale = new Vector2(0.8f),
});
AddStep("add items", () =>
{
foreach (var item in items)
grid.AddItem(item);
});
AddWaitStep("wait for panels", 3);
}
[Test]
public void TestBasic()
{
AddStep("do nothing", () =>
{
// test scene is weird.
});
AddStep("add selection 1", () => grid.ChildrenOfType<BeatmapSelectPanel>().First().AddUser(new APIUser
{
Id = DummyAPIAccess.DUMMY_USER_ID,
Username = "Maarvin",
}));
AddStep("add selection 2", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(5).First().AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}));
AddStep("add selection 3", () => grid.ChildrenOfType<BeatmapSelectPanel>().Skip(10).First().AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}));
}
[Test]
public void TestCompleteRollAnimation()
{
AddStep("play animation", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.RollAndDisplayFinalBeatmap(candidateItems, finalItem);
});
}
[Test]
public void TestRollAnimation()
{
AddStep("play animation", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
Scheduler.AddDelayed(() => grid.PlayRollAnimation(finalItem), 500);
});
}
[Test]
public void TestPresentRolledBeatmap()
{
AddStep("present beatmap", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
grid.PlayRollAnimation(finalItem, duration: 0);
Scheduler.AddDelayed(() => grid.PresentRolledBeatmap(finalItem), 500);
});
}
[Test]
public void TestPresentUnanimouslyChosenBeatmap()
{
AddStep("present beatmap", () =>
{
var (candidateItems, finalItem) = pickRandomItems(5);
grid.TransferCandidatePanelsToRollContainer(candidateItems, duration: 0);
grid.ArrangeItemsForRollAnimation(duration: 0, stagger: 0);
grid.PlayRollAnimation(finalItem, duration: 0);
Scheduler.AddDelayed(() => grid.PresentUnanimouslyChosenBeatmap(finalItem), 500);
});
}
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
[TestCase(4)]
[TestCase(5)]
[TestCase(6)]
[TestCase(7)]
[TestCase(8)]
public void TestPanelArrangement(int count)
{
AddStep("arrange panels", () =>
{
var (candidateItems, _) = pickRandomItems(count);
grid.TransferCandidatePanelsToRollContainer(candidateItems);
grid.Delay(BeatmapSelectGrid.ARRANGE_DELAY)
.Schedule(() => grid.ArrangeItemsForRollAnimation());
});
AddWaitStep("wait for movement", 5);
AddStep("display roll order", () =>
{
var panels = grid.ChildrenOfType<BeatmapSelectPanel>().ToArray();
for (int i = 0; i < panels.Length; i++)
{
var panel = panels[i];
panel.Add(new OsuSpriteText
{
Text = (i + 1).ToString(),
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Font = OsuFont.Default.With(size: 50, weight: FontWeight.SemiBold),
});
}
});
}
private (long[] candidateItems, long finalItem) pickRandomItems(int count)
{
long[] candidateItems = items.Select(it => it.ID).ToArray();
Random.Shared.Shuffle(candidateItems);
candidateItems = candidateItems.Take(count).ToArray();
long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)];
return (candidateItems, finalItem);
}
}
}

View File

@@ -0,0 +1,66 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Graphics;
using osu.Game.Graphics.Cursor;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneBeatmapSelectPanel : MultiplayerTestScene
{
[Cached]
private readonly OverlayColourProvider colourProvider = new OverlayColourProvider(OverlayColourScheme.Purple);
[Test]
public void TestBeatmapPanel()
{
BeatmapSelectPanel? panel = null;
AddStep("add panel", () =>
{
Child = new OsuContextMenuContainer
{
RelativeSizeAxes = Axes.Both,
Child = panel = new BeatmapSelectPanel(new MultiplayerPlaylistItem())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
}
};
});
AddStep("add maarvin", () => panel!.AddUser(new APIUser
{
Id = DummyAPIAccess.DUMMY_USER_ID,
Username = "Maarvin",
}));
AddStep("add peppy", () => panel!.AddUser(new APIUser
{
Id = 2,
Username = "peppy",
}));
AddStep("add smogipoo", () => panel!.AddUser(new APIUser
{
Id = 1040328,
Username = "smoogipoo",
}));
AddStep("remove smogipoo", () => panel!.RemoveUser(new APIUser { Id = 1040328 }));
AddStep("remove peppy", () => panel!.RemoveUser(new APIUser { Id = 2 }));
AddStep("remove maarvin", () => panel!.RemoveUser(new APIUser { Id = 6411631 }));
AddToggleStep("allow selection", value =>
{
if (panel != null)
panel.AllowSelection = value;
});
}
}
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingChatDisplay : ScreenTestScene
{
private MatchmakingChatDisplay? chat;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add chat", () =>
{
chat?.Expire();
ScreenFooter.Add(chat = new MatchmakingChatDisplay(new Room())
{
Anchor = Anchor.BottomRight,
Origin = Anchor.BottomRight,
Size = new Vector2(700, 130),
Margin = new MarginPadding { Bottom = 10, Right = WaveOverlayContainer.WIDTH_PADDING - OsuScreen.HORIZONTAL_OVERFLOW_PADDING },
Alpha = 0
});
});
AddStep("show footer", () => ScreenFooter.Show());
}
[Test]
public void TestAppearDisappear()
{
AddStep("appear", () => chat!.Appear());
AddWaitStep("wait for animation", 3);
AddStep("disappear", () => chat!.Disappear());
AddWaitStep("wait for animation", 3);
}
}
}

View File

@@ -0,0 +1,44 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Graphics;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingCloud : OsuTestScene
{
private CloudVisualisation cloud = null!;
protected override void LoadComplete()
{
base.LoadComplete();
Child = cloud = new CloudVisualisation
{
RelativeSizeAxes = Axes.Both,
};
}
[Test]
public void TestBasic()
{
AddStep("refresh users", () =>
{
var testUsers = Enumerable.Range(0, 50).Select(_ => new APIUser
{
Username = "peppy",
Statistics = new UserStatistics { GlobalRank = 1234 },
Id = RNG.Next(2, 30000000),
}).ToArray();
cloud.Users = testUsers;
});
}
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Online.Matchmaking;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingPoolSelector : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add selector", () => Child = new PoolSelector
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
AvailablePools =
{
Value =
[
new MatchmakingPool { Id = 0, RulesetId = 0, Name = "osu!" },
new MatchmakingPool { Id = 1, RulesetId = 1, Name = "osu!taiko" },
new MatchmakingPool { Id = 2, RulesetId = 2, Name = "osu!catch" },
new MatchmakingPool { Id = 3, RulesetId = 3, Variant = 4, Name = "osu!mania (4k)" },
new MatchmakingPool { Id = 4, RulesetId = 3, Variant = 7, Name = "osu!mania (7k)" },
]
}
});
}
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Linq;
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Testing;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Screens.OnlinePlay.Matchmaking.Intro;
using osu.Game.Screens.OnlinePlay.Matchmaking.Queue;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingQueueScreen : MultiplayerTestScene
{
[Cached]
private readonly QueueController controller = new QueueController();
private ScreenQueue? queueScreen => Stack.CurrentScreen as ScreenQueue;
[SetUpSteps]
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("load screen", () => LoadScreen(new IntroScreen()));
}
[Test]
public void TestBasic()
{
AddUntilStep("wait for queue screen", () => queueScreen?.IsLoaded == true);
AddStep("set users", () =>
{
queueScreen!.Users = Enumerable.Range(0, 10).Select(_ => new APIUser
{
Username = "peppy",
Statistics = new UserStatistics { GlobalRank = 1234 },
Id = RNG.Next(2, 30000000),
}).ToArray();
});
AddStep("change state to idle", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Idle));
AddStep("change state to queueing", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.Queueing));
AddStep("change state to found match", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.PendingAccept));
AddStep("change state to waiting for room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.AcceptedWaitingForRoom));
AddStep("change state to in room", () => queueScreen!.SetState(ScreenQueue.MatchmakingScreenState.InRoom));
}
}
}

View File

@@ -0,0 +1,200 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneMatchmakingScreen : MultiplayerTestScene
{
private const int user_count = 8;
private const int beatmap_count = 50;
private MultiplayerRoomUser[] users = null!;
private ScreenMatchmaking screen = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
JoinRoom(room);
});
WaitForJoined();
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
setupRequestHandler();
AddStep("load match", () =>
{
users = Enumerable.Range(1, user_count).Select(i => new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"Player {i}"
}
}).ToArray();
var beatmaps = Enumerable.Range(1, beatmap_count).Select(i => new MultiplayerPlaylistItem
{
BeatmapID = i,
StarRating = i / 10.0
}).ToArray();
LoadScreen(screen = new ScreenMatchmaking(new MultiplayerRoom(0)
{
Users = users,
Playlist = beatmaps
}));
});
AddUntilStep("wait for load", () => screen.IsCurrentScreen());
}
[Test]
public void TestGameplayFlow()
{
for (int round = 1; round <= 3; round++)
{
AddLabel($"Round {round}");
int r = round;
changeStage(MatchmakingStage.RoundWarmupTime, state => state.CurrentRound = r);
changeStage(MatchmakingStage.UserBeatmapSelect);
changeStage(MatchmakingStage.ServerBeatmapFinalised, state =>
{
MultiplayerPlaylistItem[] beatmaps = Enumerable.Range(1, 8).Select(i => new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
}).ToArray();
state.CandidateItems = beatmaps.Select(b => b.ID).ToArray();
state.CandidateItem = beatmaps[0].ID;
}, waitTime: 35);
changeStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
changeStage(MatchmakingStage.GameplayWarmupTime);
changeStage(MatchmakingStage.Gameplay);
changeStage(MatchmakingStage.ResultsDisplaying);
}
changeStage(MatchmakingStage.Ended, state =>
{
int i = 1;
foreach (var user in MultiplayerClient.ServerRoom!.Users.OrderBy(_ => RNG.Next()))
{
state.Users[user.UserID].Placement = i++;
state.Users[user.UserID].Points = (8 - i) * 7;
state.Users[user.UserID].Rounds[1].Placement = 1;
state.Users[user.UserID].Rounds[1].TotalScore = 1;
state.Users[user.UserID].Rounds[1].Statistics[HitResult.LargeBonus] = 1;
}
});
}
private void changeStage(MatchmakingStage stage, Action<MatchmakingRoomState>? prepare = null, int waitTime = 5)
{
AddStep($"stage: {stage}", () => MultiplayerClient.MatchmakingChangeStage(stage, prepare).WaitSafely());
AddWaitStep("wait", waitTime);
}
private void setupRequestHandler()
{
AddStep("setup request handler", () =>
{
Func<APIRequest, bool>? defaultRequestHandler = null;
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapsRequest getBeatmaps:
getBeatmaps.TriggerSuccess(new GetBeatmapsResponse
{
Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap
{
OnlineID = id,
StarRating = id,
DifficultyName = $"Beatmap {id}",
BeatmapSet = new APIBeatmapSet
{
Title = $"Title {id}",
Artist = $"Artist {id}",
AuthorString = $"Author {id}"
}
}).ToList()
});
return true;
case IndexPlaylistScoresRequest index:
var result = new IndexedMultiplayerScores();
for (int i = 0; i < 8; ++i)
{
result.Scores.Add(new MultiplayerScore
{
ID = i,
Accuracy = 1 - (float)i / 16,
Position = i + 1,
EndedAt = DateTimeOffset.Now,
Passed = true,
Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH),
MaxCombo = 1000 - i,
TotalScore = (long)(1_000_000 * (1 - (float)i / 16)),
User = new APIUser { Username = $"user {i}" },
Statistics = new Dictionary<HitResult, int>()
});
}
index.TriggerSuccess(result);
return true;
default:
return defaultRequestHandler?.Invoke(request) ?? false;
}
};
});
}
}
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using osu.Framework.Graphics;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePanelRoomAward : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("add award", () => Child = new PanelRoomAward("Award name", "Description of what this award means", 1)
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
}
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.BeatmapSelect;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePickScreen : MultiplayerTestScene
{
private readonly IReadOnlyList<APIUser> users = new[]
{
new APIUser
{
Id = 2,
Username = "peppy",
},
new APIUser
{
Id = 1040328,
Username = "smoogipoo",
},
new APIUser
{
Id = 6573093,
Username = "OliBomby",
},
new APIUser
{
Id = 7782553,
Username = "aesth",
},
new APIUser
{
Id = 6411631,
Username = "Maarvin",
}
};
private readonly PlaylistItem[] items = Enumerable.Range(1, 50).Select(i => new PlaylistItem(new MultiplayerPlaylistItem
{
ID = i,
BeatmapID = i,
StarRating = i / 10.0,
})).ToArray();
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () =>
{
var room = CreateDefaultRoom(MatchType.Matchmaking);
room.Playlist = items;
JoinRoom(room);
});
WaitForJoined();
AddStep("add users", () =>
{
foreach (var user in users)
MultiplayerClient.AddUser(user);
});
}
[Test]
public void TestScreen()
{
var selectedItems = new List<long>();
SubScreenBeatmapSelect screen = null!;
AddStep("add screen", () => Child = new ScreenStack(screen = new SubScreenBeatmapSelect()));
AddStep("select maps", () =>
{
selectedItems.Clear();
foreach (var user in users)
{
var item = items[Random.Shared.Next(items.Length)];
selectedItems.Add(item.ID);
Scheduler.AddDelayed(() =>
{
MultiplayerClient.MatchmakingToggleUserSelection(user.Id, item.ID).FireAndForget();
}, RNG.NextDouble(10, 1000));
}
});
AddStep("show final map", () =>
{
long[] candidateItems = selectedItems.ToArray();
long finalItem = candidateItems[Random.Shared.Next(candidateItems.Length)];
screen.RollFinalBeatmap(candidateItems, finalItem);
});
}
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Matchmaking.Events;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
using osu.Game.Users;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePlayerPanel : MultiplayerTestScene
{
private PlayerPanel panel = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add panel", () => Child = panel = new PlayerPanel(new MultiplayerRoomUser(1)
{
User = new APIUser
{
Username = @"peppy",
Id = 2,
Colour = "99EB47",
CountryCode = CountryCode.AU,
CoverUrl = @"https://assets.ppy.sh/user-profile-covers/2/baba245ef60834b769694178f8f6d4f6166c5188c740de084656ad2b80f1eea7.jpeg",
Statistics = new UserStatistics { GlobalRank = null, CountryRank = null }
}
})
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre
});
}
[Test]
public void TestIncreasePlacement()
{
int rank = 0;
AddStep("increase placement", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary =
{
{
2, new MatchmakingUser
{
UserId = 2,
Placement = ++rank
}
}
}
}
}).WaitSafely());
foreach (var layout in Enum.GetValues<PlayerPanelDisplayMode>())
{
AddStep($"set layout to {layout}", () => panel.DisplayMode = layout);
}
}
[Test]
public void TestIncreasePoints()
{
int points = 0;
AddStep("increase points", () => MultiplayerClient.ChangeMatchRoomState(new MatchmakingRoomState
{
Users =
{
UserDictionary =
{
{
1, new MatchmakingUser
{
UserId = 1,
Placement = 1,
Points = ++points
}
}
}
}
}).WaitSafely());
}
[Test]
public void TestJump()
{
AddStep("jump", () => MultiplayerClient.SendUserMatchRequest(1, new MatchmakingAvatarActionRequest { Action = MatchmakingAvatarAction.Jump }).WaitSafely());
}
[Test]
public void TestQuit()
{
AddToggleStep("toggle quit", quit => panel.HasQuit = quit);
}
}
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers;
using osu.Framework.Testing;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestScenePlayerPanelOverlay : MultiplayerTestScene
{
private PlayerPanelOverlay list = null!;
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add list", () => Child = new Container
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.Both,
Size = new Vector2(0.8f),
Child = list = new PlayerPanelOverlay()
});
}
[Test]
public void TestChangeDisplayMode()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
AddStep("change to hidden mode", () => list.DisplayStyle = PanelDisplayStyle.Hidden);
}
[Test]
public void AddPanelsGrid()
{
AddStep("change to grid mode", () => list.DisplayStyle = PanelDisplayStyle.Grid);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void AddPanelsSplit()
{
AddStep("change to split mode", () => list.DisplayStyle = PanelDisplayStyle.Split);
int userId = 0;
AddRepeatStep("join user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(userId)
{
User = new APIUser
{
Username = $"User {userId}"
}
});
userId++;
}, 8);
}
[Test]
public void RemovePanels()
{
AddStep("join another user", () =>
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(1)
{
User = new APIUser
{
Username = "User 1"
}
});
});
AddUntilStep("two panels displayed", () => this.ChildrenOfType<PlayerPanel>().Count(), () => Is.EqualTo(2));
AddAssert("no panels quit", () => this.ChildrenOfType<PlayerPanel>().Count(p => p.HasQuit), () => Is.EqualTo(0));
AddStep("remove a user", () => MultiplayerClient.RemoveUser(new APIUser { Id = 1 }));
AddUntilStep("one panel quit", () => this.ChildrenOfType<PlayerPanel>().Count(p => p.HasQuit), () => Is.EqualTo(1));
AddAssert("two panels still displayed", () => this.ChildrenOfType<PlayerPanel>().Count(), () => Is.EqualTo(2));
}
[Test]
public void ChangeRankings()
{
AddStep("join users", () =>
{
for (int i = 0; i < 7; i++)
{
MultiplayerClient.AddUser(new MultiplayerRoomUser(i)
{
User = new APIUser
{
Username = $"User {i}"
}
});
}
});
AddStep("set random placements", () =>
{
MultiplayerRoom room = MultiplayerClient.ServerRoom!;
int[] placements = Enumerable.Range(1, room.Users.Count).ToArray();
Random.Shared.Shuffle(placements);
MatchmakingRoomState state = new MatchmakingRoomState();
for (int i = 0; i < room.Users.Count; i++)
state.Users[room.Users[i].UserID].Placement = placements[i];
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Multiplayer;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.Results;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneResultsScreen : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("set initial results", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 6,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
// Overall state.
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Points = 8;
for (int round = 1; round <= state.CurrentRound; round++)
state.Users[localUserId].Rounds[round].Placement = round;
// Highest score.
state.Users[localUserId].Rounds[1].TotalScore = 1000;
// Highest accuracy.
state.Users[localUserId].Rounds[2].Accuracy = 0.9995;
// Highest combo.
state.Users[localUserId].Rounds[3].MaxCombo = 100;
// Most bonus score.
state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50;
// Smallest score difference.
state.Users[localUserId].Rounds[5].TotalScore = 1000;
// Largest score difference.
state.Users[localUserId].Rounds[6].TotalScore = 1000;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
AddStep("add results screen", () =>
{
Child = new ScreenStack(new SubScreenResults())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
}
[Test]
public void TestBasic()
{
AddStep("do nothing", () => { });
}
[Test]
public void TestInvalidUser()
{
const int invalid_user_id = 1;
AddStep("join another user", () => MultiplayerClient.AddUser(new MultiplayerRoomUser(invalid_user_id)
{
User = new APIUser
{
Id = invalid_user_id,
Username = "Invalid user"
}
}));
AddStep("set results stage", () =>
{
var state = new MatchmakingRoomState
{
CurrentRound = 6,
Stage = MatchmakingStage.Ended
};
int localUserId = API.LocalUser.Value.OnlineID;
// Overall state.
state.Users[localUserId].Placement = 1;
state.Users[localUserId].Points = 8;
state.Users[invalid_user_id].Placement = 2;
state.Users[invalid_user_id].Points = 7;
for (int round = 1; round <= state.CurrentRound; round++)
state.Users[localUserId].Rounds[round].Placement = round;
// Highest score.
state.Users[localUserId].Rounds[1].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[1].TotalScore = 990;
// Highest accuracy.
state.Users[localUserId].Rounds[2].Accuracy = 0.9995;
state.Users[invalid_user_id].Rounds[2].Accuracy = 0.5;
// Highest combo.
state.Users[localUserId].Rounds[3].MaxCombo = 100;
state.Users[invalid_user_id].Rounds[3].MaxCombo = 10;
// Most bonus score.
state.Users[localUserId].Rounds[4].Statistics[HitResult.LargeBonus] = 50;
state.Users[invalid_user_id].Rounds[4].Statistics[HitResult.LargeBonus] = 25;
// Smallest score difference.
state.Users[localUserId].Rounds[5].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[5].TotalScore = 999;
// Largest score difference.
state.Users[localUserId].Rounds[6].TotalScore = 1000;
state.Users[invalid_user_id].Rounds[6].TotalScore = 0;
MultiplayerClient.ChangeMatchRoomState(state).WaitSafely();
});
}
}
}

View File

@@ -0,0 +1,102 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Linq;
using osu.Framework.Graphics;
using osu.Framework.Screens;
using osu.Framework.Utils;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Rooms;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match.RoundResults;
using osu.Game.Tests.Visual.Multiplayer;
using osuTK;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneRoundResultsScreen : MultiplayerTestScene
{
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
setupRequestHandler();
AddStep("load screen", () =>
{
Child = new ScreenStack(new SubScreenRoundResults())
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
Size = new Vector2(0.8f)
};
});
}
private void setupRequestHandler()
{
AddStep("setup request handler", () =>
{
Func<APIRequest, bool>? defaultRequestHandler = null;
((DummyAPIAccess)API).HandleRequest = request =>
{
switch (request)
{
case GetBeatmapsRequest getBeatmaps:
getBeatmaps.TriggerSuccess(new GetBeatmapsResponse
{
Beatmaps = getBeatmaps.BeatmapIds.Select(id => new APIBeatmap
{
OnlineID = id,
StarRating = id,
DifficultyName = $"Beatmap {id}",
BeatmapSet = new APIBeatmapSet
{
Title = $"Title {id}",
Artist = $"Artist {id}",
AuthorString = $"Author {id}"
}
}).ToList()
});
return true;
case IndexPlaylistScoresRequest index:
var result = new IndexedMultiplayerScores();
for (int i = 0; i < 8; ++i)
{
result.Scores.Add(new MultiplayerScore
{
ID = i,
Accuracy = 1 - (float)i / 16,
Position = i + 1,
EndedAt = DateTimeOffset.Now,
Passed = true,
Rank = (ScoreRank)RNG.Next((int)ScoreRank.D, (int)ScoreRank.XH),
MaxCombo = 1000 - i,
TotalScore = (long)(1_000_000 * (1 - (float)i / 16)),
User = new APIUser { Username = $"user {i}" },
Statistics = new Dictionary<HitResult, int>()
});
}
index.TriggerSuccess(result);
return true;
default:
return defaultRequestHandler?.Invoke(request) ?? false;
}
};
});
}
}
}

View File

@@ -0,0 +1,61 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using NUnit.Framework;
using osu.Framework.Allocation;
using osu.Framework.Extensions;
using osu.Framework.Graphics;
using osu.Game.Online.Multiplayer.MatchTypes.Matchmaking;
using osu.Game.Online.Rooms;
using osu.Game.Overlays;
using osu.Game.Screens.OnlinePlay.Matchmaking.Match;
using osu.Game.Tests.Visual.Multiplayer;
namespace osu.Game.Tests.Visual.Matchmaking
{
public partial class TestSceneStageDisplay : MultiplayerTestScene
{
[Cached]
protected readonly OverlayColourProvider ColourProvider = new OverlayColourProvider(OverlayColourScheme.Plum);
public override void SetUpSteps()
{
base.SetUpSteps();
AddStep("join room", () => JoinRoom(CreateDefaultRoom(MatchType.Matchmaking)));
WaitForJoined();
AddStep("add display", () => Child = new StageDisplay
{
Anchor = Anchor.Centre,
Origin = Anchor.Centre,
RelativeSizeAxes = Axes.X,
});
}
[Test]
public void TestChangeStage()
{
addStage(MatchmakingStage.WaitingForClientsJoin);
for (int i = 1; i <= 5; i++)
{
addStage(MatchmakingStage.RoundWarmupTime);
addStage(MatchmakingStage.UserBeatmapSelect);
addStage(MatchmakingStage.ServerBeatmapFinalised);
addStage(MatchmakingStage.WaitingForClientsBeatmapDownload);
addStage(MatchmakingStage.GameplayWarmupTime);
addStage(MatchmakingStage.Gameplay);
addStage(MatchmakingStage.ResultsDisplaying);
}
addStage(MatchmakingStage.Ended);
}
private void addStage(MatchmakingStage stage)
{
AddStep($"{stage}", () => MultiplayerClient.MatchmakingChangeStage(stage).WaitSafely());
AddWaitStep("wait a bit", 10);
}
}
}

View File

@@ -1,6 +1,7 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. // Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text. // See the LICENCE file in the repository root for full licence text.
using System;
using System.Linq; using System.Linq;
using System.Net; using System.Net;
using NUnit.Framework; using NUnit.Framework;
@@ -9,10 +10,12 @@ using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Testing; using osu.Framework.Testing;
using osu.Game.Configuration; using osu.Game.Configuration;
using osu.Game.Graphics.Sprites;
using osu.Game.Graphics.UserInterface; using osu.Game.Graphics.UserInterface;
using osu.Game.Online; using osu.Game.Online;
using osu.Game.Online.API; using osu.Game.Online.API;
using osu.Game.Online.API.Requests; using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Overlays; using osu.Game.Overlays;
using osu.Game.Overlays.Login; using osu.Game.Overlays.Login;
using osu.Game.Overlays.Settings; using osu.Game.Overlays.Settings;
@@ -54,7 +57,7 @@ namespace osu.Game.Tests.Visual.Menus
} }
[Test] [Test]
public void TestLoginSuccess() public void TestLoginSuccess_EmailVerification()
{ {
AddStep("logout", () => API.Logout()); AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline); assertAPIState(APIState.Offline);
@@ -94,6 +97,152 @@ namespace osu.Game.Tests.Visual.Menus
assertDropdownState(UserAction.DoNotDisturb); assertDropdownState(UserAction.DoNotDisturb);
} }
[Test]
public void TestLoginSuccess_TOTPVerification()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "012345")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "012345");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); });
AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); });
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
assertDropdownState(UserAction.Online);
AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb));
assertDropdownState(UserAction.DoNotDisturb);
}
[Test]
public void TestLoginSuccess_TOTPVerification_FallbackToEmail()
{
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "deadbeef")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
case VerificationMailFallbackRequest verificationMailFallbackRequest:
verificationMailFallbackRequest.TriggerSuccess();
return true;
}
return false;
});
AddStep("request fallback to email", () =>
{
InputManager.MoveMouseTo(this.ChildrenOfType<OsuSpriteText>().Single(t => t.Text.ToString().Contains("email", StringComparison.InvariantCultureIgnoreCase)));
InputManager.Click(MouseButton.Left);
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "deadbeef");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
AddStep("set failing", () => { dummyAPI.SetState(APIState.Failing); });
AddStep("return to online", () => { dummyAPI.SetState(APIState.Online); });
AddStep("clear handler", () => dummyAPI.HandleRequest = null);
assertDropdownState(UserAction.Online);
AddStep("change user state", () => localConfig.SetValue(OsuSetting.UserOnlineStatus, UserStatus.DoNotDisturb));
assertDropdownState(UserAction.DoNotDisturb);
}
[Test]
public void TestLoginSuccess_TOTPVerification_TurnedOffMidwayThrough()
{
bool firstAttemptHandled = false;
AddStep("logout", () => API.Logout());
assertAPIState(APIState.Offline);
AddStep("expect totp verification", () => dummyAPI.SessionVerificationMethod = SessionVerificationMethod.TimedOneTimePassword);
AddStep("enter password", () => loginOverlay.ChildrenOfType<OsuPasswordTextBox>().First().Text = "password");
AddStep("submit", () => loginOverlay.ChildrenOfType<OsuButton>().First(b => b.Text.ToString() == "Sign in").TriggerClick());
assertAPIState(APIState.RequiresSecondFactorAuth);
AddUntilStep("wait for second factor auth form", () => loginOverlay.ChildrenOfType<SecondFactorAuthForm>().SingleOrDefault(), () => Is.Not.Null);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
verifySessionRequest.RequiredVerificationMethod = SessionVerificationMethod.EmailMessage;
verifySessionRequest.TriggerFailure(new WebException());
firstAttemptHandled = true;
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "123456");
AddUntilStep("first verification attempt handled", () => firstAttemptHandled);
assertAPIState(APIState.RequiresSecondFactorAuth);
AddStep("set up verification handling", () => dummyAPI.HandleRequest = req =>
{
switch (req)
{
case VerifySessionRequest verifySessionRequest:
if (verifySessionRequest.VerificationKey == "deadbeef")
verifySessionRequest.TriggerSuccess();
else
verifySessionRequest.TriggerFailure(new WebException());
return true;
}
return false;
});
AddStep("enter code", () => loginOverlay.ChildrenOfType<OsuTextBox>().First().Text = "deadbeef");
assertAPIState(APIState.Online);
assertDropdownState(UserAction.Online);
}
private void assertDropdownState(UserAction state) private void assertDropdownState(UserAction state)
{ {
AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType<UserDropdown>().First().Current.Value, () => Is.EqualTo(state)); AddAssert($"dropdown state is {state}", () => loginOverlay.ChildrenOfType<UserDropdown>().First().Current.Value, () => Is.EqualTo(state));

View File

@@ -6,6 +6,7 @@ using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions; using osu.Framework.Extensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Platform; using osu.Framework.Platform;
using osu.Framework.Screens; using osu.Framework.Screens;
using osu.Framework.Testing; using osu.Framework.Testing;
@@ -36,6 +37,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
private BeatmapManager beatmaps = null!; private BeatmapManager beatmaps = null!;
private BeatmapSetInfo importedSet = null!; private BeatmapSetInfo importedSet = null!;
private RulesetStore rulesets = null!;
private TestMultiplayerComponents multiplayerComponents = null!; private TestMultiplayerComponents multiplayerComponents = null!;
@@ -46,7 +48,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
BeatmapStore beatmapStore; BeatmapStore beatmapStore;
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(beatmaps = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore()); Dependencies.CacheAs(beatmapStore = new RealmDetachedBeatmapStore());
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
@@ -115,5 +117,13 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded); AddUntilStep("wait for player", () => multiplayerComponents.CurrentScreen is Player player && player.IsLoaded);
AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent()); AddStep("exit player", () => multiplayerComponents.MultiplayerScreen.MakeCurrent());
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
} }
} }

View File

@@ -8,6 +8,7 @@ using System.Linq;
using NUnit.Framework; using NUnit.Framework;
using osu.Framework.Allocation; using osu.Framework.Allocation;
using osu.Framework.Audio; using osu.Framework.Audio;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics; using osu.Framework.Graphics;
using osu.Framework.Graphics.Containers; using osu.Framework.Graphics.Containers;
using osu.Framework.Graphics.Sprites; using osu.Framework.Graphics.Sprites;
@@ -37,13 +38,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
{ {
public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene public partial class TestSceneDrawableRoomPlaylist : MultiplayerTestScene
{ {
private RulesetStore rulesets = null!;
private TestPlaylist playlist = null!; private TestPlaylist playlist = null!;
private BeatmapManager manager = null!; private BeatmapManager manager = null!;
[BackgroundDependencyLoader] [BackgroundDependencyLoader]
private void load(GameHost host, AudioManager audio) private void load(GameHost host, AudioManager audio)
{ {
Dependencies.Cache(new RealmRulesetStore(Realm)); Dependencies.Cache(rulesets = new RealmRulesetStore(Realm));
Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default)); Dependencies.Cache(manager = new BeatmapManager(LocalStorage, Realm, null, audio, Resources, host, Beatmap.Default));
Dependencies.Cache(Realm); Dependencies.Cache(Realm);
} }
@@ -436,6 +438,14 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded)); AddUntilStep("wait for items to load", () => playlist.ItemMap.Values.All(i => i.IsLoaded));
} }
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
if (rulesets.IsNotNull())
rulesets.Dispose();
}
private partial class TestPlaylist : DrawableRoomPlaylist private partial class TestPlaylist : DrawableRoomPlaylist
{ {
public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap; public new IReadOnlyDictionary<PlaylistItem, RearrangeableListItem<PlaylistItem>> ItemMap => base.ItemMap;

Some files were not shown because too many files have changed in this diff Show More