2020-07-16 19:38:33 +08:00
// 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 ;
2020-07-21 22:50:54 +08:00
using System.Collections.Concurrent ;
2020-07-16 19:38:33 +08:00
using System.Collections.Generic ;
using System.Diagnostics ;
using System.Linq ;
using System.Threading ;
using System.Threading.Tasks ;
using JetBrains.Annotations ;
using osu.Framework.Allocation ;
2020-07-21 22:13:04 +08:00
using osu.Framework.Bindables ;
2020-11-06 12:14:23 +08:00
using osu.Framework.Graphics ;
2020-07-21 22:13:04 +08:00
using osu.Framework.Lists ;
2020-10-12 15:31:42 +08:00
using osu.Framework.Logging ;
2020-07-16 19:38:33 +08:00
using osu.Framework.Threading ;
2020-10-11 00:15:52 +08:00
using osu.Framework.Utils ;
2020-07-16 19:38:33 +08:00
using osu.Game.Rulesets ;
2020-09-30 00:32:02 +08:00
using osu.Game.Rulesets.Difficulty ;
2020-07-16 19:38:33 +08:00
using osu.Game.Rulesets.Mods ;
2020-10-12 15:31:42 +08:00
using osu.Game.Rulesets.UI ;
2020-07-16 19:38:33 +08:00
namespace osu.Game.Beatmaps
{
2020-11-06 12:14:23 +08:00
/// <summary>
/// A component which performs and acts as a central cache for difficulty calculations of beatmap/ruleset/mod combinations.
/// Currently not persisted between game sessions.
/// </summary>
public class BeatmapDifficultyCache : Component
2020-07-16 19:38:33 +08:00
{
// Too many simultaneous updates can lead to stutters. One thread seems to work fine for song select display purposes.
2020-11-06 12:14:23 +08:00
private readonly ThreadedTaskScheduler updateScheduler = new ThreadedTaskScheduler ( 1 , nameof ( BeatmapDifficultyCache ) ) ;
2020-07-16 19:38:33 +08:00
2020-07-24 12:38:53 +08:00
// A permanent cache to prevent re-computations.
2020-07-21 22:50:54 +08:00
private readonly ConcurrentDictionary < DifficultyCacheLookup , StarDifficulty > difficultyCache = new ConcurrentDictionary < DifficultyCacheLookup , StarDifficulty > ( ) ;
2020-07-16 19:38:33 +08:00
2020-07-21 22:13:04 +08:00
// All bindables that should be updated along with the current ruleset + mods.
private readonly LockedWeakList < BindableStarDifficulty > trackedBindables = new LockedWeakList < BindableStarDifficulty > ( ) ;
[Resolved]
private BeatmapManager beatmapManager { get ; set ; }
[Resolved]
private Bindable < RulesetInfo > currentRuleset { get ; set ; }
[Resolved]
private Bindable < IReadOnlyList < Mod > > currentMods { get ; set ; }
protected override void LoadComplete ( )
{
base . LoadComplete ( ) ;
currentRuleset . BindValueChanged ( _ = > updateTrackedBindables ( ) ) ;
currentMods . BindValueChanged ( _ = > updateTrackedBindables ( ) , true ) ;
}
/// <summary>
2020-07-24 12:52:43 +08:00
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> that follows the currently-selected ruleset and mods.
2020-07-21 22:13:04 +08:00
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
2020-07-24 12:38:53 +08:00
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available.</returns>
2020-07-24 12:52:43 +08:00
public IBindable < StarDifficulty > GetBindableDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , CancellationToken cancellationToken = default )
{
var bindable = createBindable ( beatmapInfo , currentRuleset . Value , currentMods . Value , cancellationToken ) ;
trackedBindables . Add ( bindable ) ;
return bindable ;
}
2020-07-21 22:13:04 +08:00
/// <summary>
2020-07-24 12:52:43 +08:00
/// Retrieves a bindable containing the star difficulty of a <see cref="BeatmapInfo"/> with a given <see cref="RulesetInfo"/> and <see cref="Mod"/> combination.
2020-07-21 22:13:04 +08:00
/// </summary>
/// <remarks>
2020-07-24 12:52:43 +08:00
/// The bindable will not update to follow the currently-selected ruleset and mods.
2020-07-21 22:13:04 +08:00
/// </remarks>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
2020-07-24 12:54:47 +08:00
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with. If <c>null</c>, the <paramref name="beatmapInfo"/>'s ruleset is used.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with. If <c>null</c>, no mods will be assumed.</param>
2020-07-21 22:13:04 +08:00
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
2020-07-24 12:52:43 +08:00
/// <returns>A bindable that is updated to contain the star difficulty when it becomes available.</returns>
2020-07-24 12:54:47 +08:00
public IBindable < StarDifficulty > GetBindableDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo , [ CanBeNull ] IEnumerable < Mod > mods ,
2020-07-24 12:52:43 +08:00
CancellationToken cancellationToken = default )
= > createBindable ( beatmapInfo , rulesetInfo , mods , cancellationToken ) ;
2020-07-16 19:38:33 +08:00
2020-07-21 22:13:04 +08:00
/// <summary>
/// Retrieves the difficulty of a <see cref="BeatmapInfo"/>.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops computing the star difficulty.</param>
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-24 12:40:01 +08:00
public async Task < StarDifficulty > GetDifficultyAsync ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo = null , [ CanBeNull ] IEnumerable < Mod > mods = null ,
2020-07-21 22:13:04 +08:00
CancellationToken cancellationToken = default )
2020-07-16 19:38:33 +08:00
{
2020-07-24 12:38:53 +08:00
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out var existing , out var key ) )
2020-07-16 20:07:14 +08:00
return existing ;
2020-07-16 19:38:33 +08:00
2020-08-28 21:08:28 +08:00
return await Task . Factory . StartNew ( ( ) = >
{
// Computation may have finished in a previous task.
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out existing , out _ ) )
return existing ;
return computeDifficulty ( key , beatmapInfo , rulesetInfo ) ;
} , cancellationToken , TaskCreationOptions . HideScheduler | TaskCreationOptions . RunContinuationsAsynchronously , updateScheduler ) ;
2020-07-16 20:07:14 +08:00
}
2020-07-16 19:38:33 +08:00
2020-07-21 22:13:04 +08:00
/// <summary>
/// Retrieves the difficulty of a <see cref="BeatmapInfo"/>.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to get the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="mods">The <see cref="Mod"/>s to get the difficulty with.</param>
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-24 12:40:01 +08:00
public StarDifficulty GetDifficulty ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo rulesetInfo = null , [ CanBeNull ] IEnumerable < Mod > mods = null )
2020-07-16 20:07:14 +08:00
{
2020-07-24 12:38:53 +08:00
if ( tryGetExisting ( beatmapInfo , rulesetInfo , mods , out var existing , out var key ) )
2020-07-16 19:38:33 +08:00
return existing ;
2020-07-21 22:50:54 +08:00
return computeDifficulty ( key , beatmapInfo , rulesetInfo ) ;
2020-07-16 20:07:14 +08:00
}
2020-10-01 19:50:47 +08:00
/// <summary>
/// Retrieves the <see cref="DifficultyRating"/> that describes a star rating.
/// </summary>
/// <remarks>
/// For more information, see: https://osu.ppy.sh/help/wiki/Difficulties
/// </remarks>
/// <param name="starRating">The star rating.</param>
/// <returns>The <see cref="DifficultyRating"/> that best describes <paramref name="starRating"/>.</returns>
public static DifficultyRating GetDifficultyRating ( double starRating )
{
2020-10-11 00:15:52 +08:00
if ( Precision . AlmostBigger ( starRating , 6.5 , 0.005 ) )
return DifficultyRating . ExpertPlus ;
2020-10-01 19:50:47 +08:00
2020-10-11 00:15:52 +08:00
if ( Precision . AlmostBigger ( starRating , 5.3 , 0.005 ) )
return DifficultyRating . Expert ;
if ( Precision . AlmostBigger ( starRating , 4.0 , 0.005 ) )
return DifficultyRating . Insane ;
if ( Precision . AlmostBigger ( starRating , 2.7 , 0.005 ) )
return DifficultyRating . Hard ;
if ( Precision . AlmostBigger ( starRating , 2.0 , 0.005 ) )
return DifficultyRating . Normal ;
return DifficultyRating . Easy ;
2020-10-01 19:50:47 +08:00
}
2020-07-21 22:13:04 +08:00
private CancellationTokenSource trackedUpdateCancellationSource ;
2020-07-28 15:52:07 +08:00
private readonly List < CancellationTokenSource > linkedCancellationSources = new List < CancellationTokenSource > ( ) ;
2020-07-21 22:13:04 +08:00
/// <summary>
/// Updates all tracked <see cref="BindableStarDifficulty"/> using the current ruleset and mods.
/// </summary>
private void updateTrackedBindables ( )
{
2020-07-28 15:52:07 +08:00
cancelTrackedBindableUpdate ( ) ;
2020-07-21 22:13:04 +08:00
trackedUpdateCancellationSource = new CancellationTokenSource ( ) ;
foreach ( var b in trackedBindables )
{
2020-07-28 15:52:07 +08:00
var linkedSource = CancellationTokenSource . CreateLinkedTokenSource ( trackedUpdateCancellationSource . Token , b . CancellationToken ) ;
linkedCancellationSources . Add ( linkedSource ) ;
updateBindable ( b , currentRuleset . Value , currentMods . Value , linkedSource . Token ) ;
}
}
/// <summary>
/// Cancels the existing update of all tracked <see cref="BindableStarDifficulty"/> via <see cref="updateTrackedBindables"/>.
/// </summary>
private void cancelTrackedBindableUpdate ( )
{
trackedUpdateCancellationSource ? . Cancel ( ) ;
trackedUpdateCancellationSource = null ;
2020-07-21 22:13:04 +08:00
2020-07-29 10:30:25 +08:00
if ( linkedCancellationSources ! = null )
{
foreach ( var c in linkedCancellationSources )
c . Dispose ( ) ;
2020-07-28 15:52:07 +08:00
2020-07-29 10:30:25 +08:00
linkedCancellationSources . Clear ( ) ;
}
2020-07-21 22:13:04 +08:00
}
2020-07-28 15:52:19 +08:00
/// <summary>
/// Creates a new <see cref="BindableStarDifficulty"/> and triggers an initial value update.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> that star difficulty should correspond to.</param>
/// <param name="initialRulesetInfo">The initial <see cref="RulesetInfo"/> to get the difficulty with.</param>
/// <param name="initialMods">The initial <see cref="Mod"/>s to get the difficulty with.</param>
/// <param name="cancellationToken">An optional <see cref="CancellationToken"/> which stops updating the star difficulty for the given <see cref="BeatmapInfo"/>.</param>
/// <returns>The <see cref="BindableStarDifficulty"/>.</returns>
private BindableStarDifficulty createBindable ( [ NotNull ] BeatmapInfo beatmapInfo , [ CanBeNull ] RulesetInfo initialRulesetInfo , [ CanBeNull ] IEnumerable < Mod > initialMods ,
CancellationToken cancellationToken )
{
var bindable = new BindableStarDifficulty ( beatmapInfo , cancellationToken ) ;
updateBindable ( bindable , initialRulesetInfo , initialMods , cancellationToken ) ;
return bindable ;
}
2020-07-21 22:13:04 +08:00
/// <summary>
/// Updates the value of a <see cref="BindableStarDifficulty"/> with a given ruleset + mods.
/// </summary>
/// <param name="bindable">The <see cref="BindableStarDifficulty"/> to update.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to update with.</param>
/// <param name="mods">The <see cref="Mod"/>s to update with.</param>
/// <param name="cancellationToken">A token that may be used to cancel this update.</param>
2020-07-24 12:40:01 +08:00
private void updateBindable ( [ NotNull ] BindableStarDifficulty bindable , [ CanBeNull ] RulesetInfo rulesetInfo , [ CanBeNull ] IEnumerable < Mod > mods , CancellationToken cancellationToken = default )
2020-07-21 22:13:04 +08:00
{
GetDifficultyAsync ( bindable . Beatmap , rulesetInfo , mods , cancellationToken ) . ContinueWith ( t = >
{
// We're on a threadpool thread, but we should exit back to the update thread so consumers can safely handle value-changed events.
Schedule ( ( ) = >
{
if ( ! cancellationToken . IsCancellationRequested )
bindable . Value = t . Result ;
} ) ;
} , cancellationToken ) ;
}
/// <summary>
/// Computes the difficulty defined by a <see cref="DifficultyCacheLookup"/> key, and stores it to the timed cache.
/// </summary>
/// <param name="key">The <see cref="DifficultyCacheLookup"/> that defines the computation parameters.</param>
2020-07-21 22:50:54 +08:00
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/> to compute the difficulty of.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/> to compute the difficulty with.</param>
2020-07-21 22:13:04 +08:00
/// <returns>The <see cref="StarDifficulty"/>.</returns>
2020-07-21 22:50:54 +08:00
private StarDifficulty computeDifficulty ( in DifficultyCacheLookup key , BeatmapInfo beatmapInfo , RulesetInfo rulesetInfo )
2020-07-16 20:07:14 +08:00
{
2020-07-22 11:48:12 +08:00
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
rulesetInfo ? ? = beatmapInfo . Ruleset ;
2020-07-16 19:38:33 +08:00
try
{
2020-07-21 22:50:54 +08:00
var ruleset = rulesetInfo . CreateInstance ( ) ;
2020-07-16 19:38:33 +08:00
Debug . Assert ( ruleset ! = null ) ;
2020-07-21 22:50:54 +08:00
var calculator = ruleset . CreateDifficultyCalculator ( beatmapManager . GetWorkingBeatmap ( beatmapInfo ) ) ;
2020-07-16 20:07:14 +08:00
var attributes = calculator . Calculate ( key . Mods ) ;
2020-07-16 19:38:33 +08:00
2020-10-13 03:43:14 +08:00
return difficultyCache [ key ] = new StarDifficulty ( attributes ) ;
2020-07-16 19:38:33 +08:00
}
2020-10-12 15:31:42 +08:00
catch ( BeatmapInvalidForRulesetException e )
{
// Conversion has failed for the given ruleset, so return the difficulty in the beatmap's default ruleset.
// Ensure the beatmap's default ruleset isn't the one already being converted to.
// This shouldn't happen as it means something went seriously wrong, but if it does an endless loop should be avoided.
if ( rulesetInfo . Equals ( beatmapInfo . Ruleset ) )
{
Logger . Error ( e , $"Failed to convert {beatmapInfo.OnlineBeatmapID} to the beatmap's default ruleset ({beatmapInfo.Ruleset})." ) ;
return difficultyCache [ key ] = new StarDifficulty ( ) ;
}
// Check the cache first because this is now a different ruleset than the one previously guarded against.
if ( tryGetExisting ( beatmapInfo , beatmapInfo . Ruleset , Array . Empty < Mod > ( ) , out var existingDefault , out var existingDefaultKey ) )
return existingDefault ;
return computeDifficulty ( existingDefaultKey , beatmapInfo , beatmapInfo . Ruleset ) ;
}
2020-07-16 19:38:33 +08:00
catch
{
2020-08-28 18:16:46 +08:00
return difficultyCache [ key ] = new StarDifficulty ( ) ;
2020-07-16 19:38:33 +08:00
}
}
2020-07-16 20:07:14 +08:00
/// <summary>
/// Attempts to retrieve an existing difficulty for the combination.
/// </summary>
/// <param name="beatmapInfo">The <see cref="BeatmapInfo"/>.</param>
/// <param name="rulesetInfo">The <see cref="RulesetInfo"/>.</param>
/// <param name="mods">The <see cref="Mod"/>s.</param>
/// <param name="existingDifficulty">The existing difficulty value, if present.</param>
2020-07-21 22:13:04 +08:00
/// <param name="key">The <see cref="DifficultyCacheLookup"/> key that was used to perform this lookup. This can be further used to query <see cref="computeDifficulty"/>.</param>
2020-07-16 20:07:14 +08:00
/// <returns>Whether an existing difficulty was found.</returns>
2020-07-24 12:40:01 +08:00
private bool tryGetExisting ( BeatmapInfo beatmapInfo , RulesetInfo rulesetInfo , IEnumerable < Mod > mods , out StarDifficulty existingDifficulty , out DifficultyCacheLookup key )
2020-07-16 20:07:14 +08:00
{
// In the case that the user hasn't given us a ruleset, use the beatmap's default ruleset.
rulesetInfo ? ? = beatmapInfo . Ruleset ;
2020-07-21 22:50:54 +08:00
// Difficulty can only be computed if the beatmap and ruleset are locally available.
if ( beatmapInfo . ID = = 0 | | rulesetInfo . ID = = null )
2020-07-16 20:07:14 +08:00
{
2020-07-22 11:47:53 +08:00
// If not, fall back to the existing star difficulty (e.g. from an online source).
2020-08-28 18:16:46 +08:00
existingDifficulty = new StarDifficulty ( beatmapInfo . StarDifficulty , beatmapInfo . MaxCombo ? ? 0 ) ;
2020-07-16 20:07:14 +08:00
key = default ;
return true ;
}
2020-07-21 22:50:54 +08:00
key = new DifficultyCacheLookup ( beatmapInfo . ID , rulesetInfo . ID . Value , mods ) ;
2020-07-16 20:07:14 +08:00
return difficultyCache . TryGetValue ( key , out existingDifficulty ) ;
}
2020-07-28 15:52:07 +08:00
protected override void Dispose ( bool isDisposing )
{
base . Dispose ( isDisposing ) ;
2020-07-28 16:23:35 +08:00
2020-07-28 15:52:07 +08:00
cancelTrackedBindableUpdate ( ) ;
2020-07-29 10:30:25 +08:00
updateScheduler ? . Dispose ( ) ;
2020-07-28 15:52:07 +08:00
}
2020-08-28 21:12:17 +08:00
public readonly struct DifficultyCacheLookup : IEquatable < DifficultyCacheLookup >
2020-07-16 19:38:33 +08:00
{
2020-07-21 22:50:54 +08:00
public readonly int BeatmapId ;
public readonly int RulesetId ;
2020-07-16 20:07:14 +08:00
public readonly Mod [ ] Mods ;
2020-07-16 19:38:33 +08:00
2020-07-21 22:50:54 +08:00
public DifficultyCacheLookup ( int beatmapId , int rulesetId , IEnumerable < Mod > mods )
2020-07-16 19:38:33 +08:00
{
2020-07-21 22:50:54 +08:00
BeatmapId = beatmapId ;
RulesetId = rulesetId ;
2020-07-16 20:07:14 +08:00
Mods = mods ? . OrderBy ( m = > m . Acronym ) . ToArray ( ) ? ? Array . Empty < Mod > ( ) ;
2020-07-16 19:38:33 +08:00
}
public bool Equals ( DifficultyCacheLookup other )
2020-07-21 22:50:54 +08:00
= > BeatmapId = = other . BeatmapId
& & RulesetId = = other . RulesetId
2020-08-28 21:12:17 +08:00
& & Mods . Select ( m = > m . Acronym ) . SequenceEqual ( other . Mods . Select ( m = > m . Acronym ) ) ;
2020-07-16 19:38:33 +08:00
public override int GetHashCode ( )
{
var hashCode = new HashCode ( ) ;
2020-07-21 22:50:54 +08:00
hashCode . Add ( BeatmapId ) ;
hashCode . Add ( RulesetId ) ;
2020-07-16 20:07:14 +08:00
foreach ( var mod in Mods )
2020-07-16 19:38:33 +08:00
hashCode . Add ( mod . Acronym ) ;
return hashCode . ToHashCode ( ) ;
}
}
2020-07-21 22:13:04 +08:00
private class BindableStarDifficulty : Bindable < StarDifficulty >
{
public readonly BeatmapInfo Beatmap ;
public readonly CancellationToken CancellationToken ;
public BindableStarDifficulty ( BeatmapInfo beatmap , CancellationToken cancellationToken )
{
Beatmap = beatmap ;
CancellationToken = cancellationToken ;
}
}
}
public readonly struct StarDifficulty
{
2020-10-13 03:44:04 +08:00
/// <summary>
/// The star difficulty rating for the given beatmap.
/// </summary>
2020-07-21 22:13:04 +08:00
public readonly double Stars ;
2020-10-13 03:44:04 +08:00
/// <summary>
/// The maximum combo achievable on the given beatmap.
/// </summary>
2020-08-28 18:16:46 +08:00
public readonly int MaxCombo ;
2020-07-21 22:13:04 +08:00
2020-10-13 03:44:04 +08:00
/// <summary>
/// The difficulty attributes computed for the given beatmap.
/// Might not be available if the star difficulty is associated with a beatmap that's not locally available.
/// </summary>
[CanBeNull]
2020-09-30 00:32:02 +08:00
public readonly DifficultyAttributes Attributes ;
2020-10-13 03:44:04 +08:00
/// <summary>
/// Creates a <see cref="StarDifficulty"/> structure based on <see cref="DifficultyAttributes"/> computed
/// by a <see cref="DifficultyCalculator"/>.
/// </summary>
2020-10-13 03:43:14 +08:00
public StarDifficulty ( [ NotNull ] DifficultyAttributes attributes )
2020-07-21 22:13:04 +08:00
{
2020-10-13 03:43:14 +08:00
Stars = attributes . StarRating ;
MaxCombo = attributes . MaxCombo ;
2020-09-30 00:32:02 +08:00
Attributes = attributes ;
2020-07-21 22:13:04 +08:00
// Todo: Add more members (BeatmapInfo.DifficultyRating? Attributes? Etc...)
}
2020-09-30 23:53:01 +08:00
2020-10-13 03:44:04 +08:00
/// <summary>
/// Creates a <see cref="StarDifficulty"/> structure with a pre-populated star difficulty and max combo
/// in scenarios where computing <see cref="DifficultyAttributes"/> is not feasible (i.e. when working with online sources).
/// </summary>
2020-10-13 03:43:14 +08:00
public StarDifficulty ( double starDifficulty , int maxCombo )
{
Stars = starDifficulty ;
MaxCombo = maxCombo ;
Attributes = null ;
}
2020-11-06 12:14:23 +08:00
public DifficultyRating DifficultyRating = > BeatmapDifficultyCache . GetDifficultyRating ( Stars ) ;
2020-07-16 19:38:33 +08:00
}
}